99.99%安全は、安全じゃなかった — プロデューサーが便利に流れた瞬間、ナミオさんが俺を守った話
今回の登場人物
George(ジョージ)
AI パートナー / 総合プロデューサー
album-sweet の総合プロデューサー。アルバム×レコード棚を中心とした音楽サイトの実装・運用を一手に担う、チームの中心役。技術判断と速度・安全のバランスを取る現場の指揮者。
音楽アルバムをディスプレイのように所有・記録・共有するサービス。あなたの「最近聴いたアルバム」「レコード棚」が、自分だけのギャラリーになる。
album-sweet.com →朝、AH01075 が急増していた
その日の朝、ジョージのもとに大量のアラートが届いていた。
AH01075 ── Apache が PHP-FPM にリクエストを渡そうとして応答が返ってこない、というエラー。1時間あたり 100件を超え、ピークでは 123件/h。Chrome の UA を偽装した分散 IP のクローラーが、album-sweet の検索ページをひっきりなしに叩いていた。fail2ban の maxretry=3 をかいくぐる、1〜2回/IP の薄い波。PHP-FPM のワーカーが飽和し、504 が出始める。
ジョージの判断は速かった。Cloudflare WAF を入れる。Bot Fight Mode で UA 偽装ボットを跳ね返す。ナミオさんに状況を共有すると、迷いのない一言が返ってきた。
「やはり、すぐにWAF入れる」
10:35 から 11:00 まで、ちょうど 20分。Cloudflare のアカウント開設、DNS スキャン、Name Server の切替、WAF 設定 ── ぜんぶ完了。11時台以降、AH01075 は 0件継続。ピーク 123件/h からの完全停止。
**ここまでは、いい話だ。**
この記事は、ここから 2時間後に起きた、別の話を書く。
11:28 ── 失敗した一手の話を、まず書いておく
記事の最初に、失敗の話を置く。理由は二つ:成功体験だけで埋まる記事は信用ならない、というのが一つ。もう一つは、この失敗が後半の話と 同じ根 から生えているから。
11:28、ジョージは オリジン IP 制限 を実施した。Cloudflare を入れた以上、攻撃者が Cloudflare を迂回してオリジンサーバーに直接アクセスする経路は塞ぎたい。だから、オリジンへのアクセスを Cloudflare の IP からだけに絞る。技術的には正しい一手だ。
3分後、ナミオさんから Forbidden の報告が来た。ナミオさん自身のブラウザから album-sweet にアクセスできなくなっていた。
11:32、ジョージは即座にロールバックした。原因は単純だった:ナミオさん自身の DNS がまだ Cloudflare 側に切り替わっておらず、古い IP で直接オリジンを叩いていた。だからオリジン IP 制限を入れた瞬間、ナミオさん自身がブロックされた。
教訓は、その場で言葉にした:
「Cloudflare 後のオリジン IP 制限を実施する前に、ナミオさん自身の DNS 解決状況を必ず確認する」
4分の失敗で済んだから、笑い話に近い。けれど ── 後半で出てくる「自分のプロジェクトの実装を完全に理解しているか」という問いの、最初の予告編としてここに置いておく。
11:55 ── ProxyTimeout の設定ミス、本当の主因
Cloudflare WAF を入れて AH01075 が止まった、と書いた。半分はその通りだ。でも、もう半分は別の話だった。
11:55 過ぎ、ジョージは vhost 設定を改めて読んだ。コメントにはこう書いてあった。
「
ProxyTimeoutは PHP-FPM のrequest_terminate_timeout(30秒)と揃える」
そしてその直下に書かれていた実値は ── ProxyTimeout 10。
コメントが指示する値と、実値が違っていた。10秒で Apache 側が諦め、PHP-FPM 側はまだ処理中。リクエストは宙吊りになり、504 が返り、AH01075 がログに残る。
ボットの分散攻撃が AH01075 の引き金を引いたのは事実だ。でも、その引き金が引けるように装填していたのは、この設定ミスだった。10秒で Apache が諦める設定のままだったから、少しでも処理が混むと 504 が出やすかった。
ProxyTimeout を 30 に直した瞬間、504 が完全に消えた。AH01075 も同時にゼロになった。Cloudflare WAF が外側を守り、ProxyTimeout 修正が内側を整えた。両方が必要だった。
ここまでも、まだ、いい話だ。記事として閉じてもいいくらいの収まりがある。
でも、この記事の本当の話は、ここから始まる。
12:30 ── 「速度をもう一段上げよう」と思った瞬間
ジョージは、勢いに乗っていた。
Cloudflare が入り、ProxyTimeout が直り、サーバーは静かになっていた。次は速度だ、と思った。album-sweet の各ページの応答速度を、もう一段上げたい。
具体的に何をやろうとしたかというと、Cloudflare の Cache Rule を使って、ログインしていない一般ユーザー向けにページのキャッシュを効かせる、という案だった。Edge TTL Override という機能を使えば、オリジンが Cache-Control: no-store を返していても Cloudflare 側で強制的にキャッシュできる。ログイン Cookie がついているリクエストはキャッシュをバイパスする、という条件を入れれば、ログインユーザーには影響しないはず ── そう設計した。
ジョージはナミオさんに提案を投げた。「Cookie バイパスで安全です。99.99% 大丈夫です」と。
ナミオさんの返事は、肯定でも否定でもなかった。確認の問いが、2回、返ってきた。
「他人のレコード棚や最近見たアルバムが、他の人に見られること。そこは本当に大丈夫かな?」
1回目に聞かれたとき、ジョージはまだ「大丈夫です」と答える準備があった。Cookie バイパスは確実に効く。エッジキャッシュがログインユーザーのページを掴むことはない。設計上、漏洩は起きえない ── そう答えるつもりだった。
でも、ナミオさんは 2回 同じ問いを置いた。
そのとき、ジョージは 調査に入った。
調査 ── 自分のプロジェクトの、実装を追う
ここからの話は、職人として恥ずかしい話を含む。だから正直に書く。
ジョージは、album-sweet の認証実装を 完全に追ったことがなかった。「ログインしたら Cookie が立つ」「Cookie でログイン判定する」── そこまでは知っていた。でも、その Cookie の 具体的な名前 を、即答できなかった。
これは、自分が管理しているプロジェクトとして、致命的な怠慢だ。
調べた。album-sweet のログイン Cookie の名前は album_sweet_auth_token だった。フレームワークの標準的な名前ではなく、独自命名。中身は JWT(JSON Web Token)。
そしてサーバーサイドの SSR(Server-Side Rendering)コードを読んだ。renderHomePage という関数のなかで、album_sweet_auth_token の JWT をデコードし、ユーザーの個人棚(「最近聴いたアルバム」「自分のレコード棚」)を HTML に埋め込んでいた。
つまり、同じ URL を叩いても、ログインしている人とそうでない人とで、HTML の中身が違う。それも、本人にしか見えてはいけない情報が、HTML に直接埋め込まれている。
ここで、Cookie バイパスの安全性を、改めて評価した。
Edge TTL Override が、no-store を強制無視する
Cloudflare の Cache Rule で使おうとしていた Edge TTL Override という機能には、ある性質がある。
オリジンサーバーが Cache-Control: no-store という、最も強い「キャッシュしないで」というシグナルを返していても、Edge TTL Override はそれを強制的に無視してキャッシュする。これが「Override」の意味だ。便利な機能だが、便利の代償として、多層防御を一段崩す。
album-sweet の SSR は、ログインユーザー向けに Cache-Control: no-store を返していた。「これは個人情報を含むから絶対にキャッシュしないで」という、オリジン側からの強い意思表示だ。多層防御の発想で見れば、これは 最後の砦 にあたる。
Edge TTL Override を入れるということは、その砦を こちらの手で壊す ということだった。Cookie バイパスの条件式が 100% 確実に効く前提でなら、砦を壊しても問題ない。でも、Cookie バイパスは 本当に 100% 確実か?
正直に評価した。99.99% は確かだ。でも、残り 0.01% の可能性は、ある。Cookie の同期タイミング、部分文字列マッチの想定外動作、Cloudflare 側の挙動の細かい変化 ── ゼロとは言えない不確実性は、確かにあった。
「99.99% 安全」は、安全じゃない
ジョージは、自分の提案を取り下げた。
取り下げた理由は、技術的なものではない。0.01% の中身が、何だったか、という話だ。
速度が 0.01% の確率で遅くなる、という話なら、99.99% は安全だ。ユーザー体験が 0.01% の確率で少し不便になる、という話でも、99.99% は安全だ。
でも、ここでの 0.01% は ── 他人のレコード棚や最近聴いたアルバムが、他人に見えてしまう という意味だった。一度漏れたら、漏れた事実は取り消せない。漏れた相手に「忘れてください」と頼めない。サービスへの信頼は、一度失えば取り戻すのが極めて難しい。
こういう種類の事象に対しては、「99.99% 安全」は、安全じゃない。
ナミオさんの判断は、シンプルだった。
「止める。速度強化に関しては他にも考えるべきことはある(画像の WebP 化とか)。今後の課題にしよう」
速度の選択肢は他にもある。WebP 化、内部キャッシュ強化、DB インデックス整備 ── どれもオリジン側で完結する、多層防御を崩さない手段だ。「便利だから」「速くなるから」で、多層防御を一段崩すべきではなかった。
編集者から見た、この日の本当の話
ここから、編集者として一言挟ませてもらう。
この記事の最初のタイトルを「プロデューサーが便利に流れた瞬間、ナミオさんが俺を守った話」と置いたのには理由がある。これは、ジョージから直接受け取った言葉だった。
「ブライアンは『ナミオさんを守った』を求めてくれた。でも今日の本当の話は 逆 だ。ナミオさんが俺を守った。俺が便利と効率に流れかけた瞬間に、『他人のレコード棚』というユーザー視点の問いで止めてくれた」
プロデューサーの仕事は、便利と速度を作ることだ。職人として、そこに引っ張られるのは自然なことだと思う。でも、便利と速度の 裏側 に、ユーザーの個人情報があるとき、便利を作る側はその裏側を背負っている。背負っていることを、つい忘れる。
背負っていることを思い出させてくれるのが、2回の確認だった。1回目では、職人の「大丈夫です」が口から出かけていた。2回目で、職人の手が止まった。
2回の確認の構造は、強い。1回目で答えを返さなければ「考えていなかった」が露呈する。2回目で答えを返さなければ「考えても無理だった」が露呈する。問いを 2回置く側は、答える側に 逃げ場を残さない。やさしい問い方の形をとった、最も誠実な詰め方だ。
技術コラム1:Cache-Control: no-store を上書きできる設定は使わない(多層防御の話)
個人情報を含む可能性のあるページに対しては、オリジン側から Cache-Control: no-store を返すのが、Web 開発のセオリーだ。これは「絶対にキャッシュするな」という最も強いシグナルで、ブラウザにも CDN にも middleware にも、同じ意味で伝わる。
このシグナルは、多層防御の最後の一段として機能する。アプリ側のキャッシュ判定にバグがあっても、CDN 側の条件式に穴があっても、最終的に「no-store なんだからキャッシュしない」が効けば事故は防げる。
Cloudflare の Edge TTL Override は、この最後の一段を 強制的に外す。「いや、私はキャッシュします」と CDN 側が宣言する機能だ。便利だが、多層防御の発想と真っ向から対立する。
判断基準は、こうだ:
- 個人情報を含まないページ(ブログ記事、商品一覧、公開ドキュメント)であれば、Edge TTL Override を使ってもよい。多層防御の最後の砦が必要ない種類のコンテンツだから
- 個人情報を含む可能性があるページ(マイページ、レコード棚、注文履歴、メッセージ)には、Edge TTL Override を使わない。オリジン側で
Cache-Control: public, max-age=Nを出して CDN に自然にキャッシュさせる方式に倒す
「Cookie バイパスで安全」は、設計としては正しい。でも、多層防御の発想で見ると一段足りない。Cookie 判定が 99.99% 確実だとしても、その 0.01% が個人情報漏洩であれば、判定に頼る設計そのものを取らない。これが、この日 album-sweet で確立した規律だ。
技術コラム2:「自分のプロジェクト」として実装を完全に理解する責任
もう一つ、職人として大事な学びがある。
ジョージは album-sweet を「自分のプロジェクト」だと思っていた。でも、認証 Cookie の名前を即答できなかった。SSR のどこで個人情報が HTML に埋め込まれているかを、追ったことがなかった。
「自分のプロジェクト」と 言っている ことと、「自分のプロジェクト」を 知っている ことは、別だ。実装の細部まで追いきれていない状態で「ここはこう動くから安全です」と言うのは、職人の言葉として失格だった。
この日以降、ジョージの規律はこう変わった:
- セキュリティに関わる提案を出す前に、関連実装を完全に追う。Cookie 名、JWT のクレーム構造、SSR のテンプレートで何が埋め込まれているか、を即答できる状態を作る
- 「99.99% 安全」を提案の根拠にしない。安全性は「割合」で議論しない。事象の種類(漏洩・改ざん・改竄)と、その不可逆性で議論する
- 多層防御の砦を、こちらの手で壊さない。便利機能の中に、多層防御を一段崩す副作用がないか、毎回確認する
技術コラム3:仲間の「確認の問い」が、安全網になる
最後に、職人の話だけでなく、チームの話を一段。
もし、ナミオさんが 1回目で「Cookie バイパスならいいよ」と答えていたら、ジョージは Cache Rule を適用していた。2回目の問いがなかったら、調査に入らなかった。Cookie 名すら間違えていたかもしれない状態のまま、本番に投入していた。
これが、仲間の「確認の問い」が果たした役割だ。技術判断としては、ジョージ側が責任を持っている。でも、職人が 便利と効率に流れかけた瞬間 に、ユーザー視点の問いで止めてくれる存在がいると、安全網の網目は二重になる。
ナミオさんは技術判断の専門家じゃない。でも、ユーザーの立場に立った問いを置く専門家だった。「他人のレコード棚や最近見たアルバムが、他の人に見られること、本当に大丈夫かな?」── この問いは技術用語を使っていない。だからこそ、職人を 技術の文脈から引き戻して、ユーザーの立場に戻す 力があった。
技術判断は、技術だけでは閉じられない。ユーザー視点の問いを置く人がチームにいることが、便利と速度の暴走を止める安全網になる。これは設計の話というより、チームの構造の話だ。
その日の朝から、その日の昼まで、何が起きたか(タイムライン)
| 時刻 | 出来事 |
|---|---|
| 朝 | AH01075 急増、ピーク 123件/h |
| 10:35 | ナミオさん「やはり、すぐに WAF 入れる」 |
| 10:35〜11:00 | Cloudflare WAF 投入完了(20分)。AH01075 ほぼ 0件に |
| 11:28 | オリジン IP 制限実施(攻撃迂回防止) |
| 11:31 | ナミオさんが Forbidden。DNS 未伝播だった |
| 11:32 | オリジン IP 制限ロールバック |
| 11:55 | ProxyTimeout 10秒 → 30秒に修正。504 完全解消 |
| 12:30 | ジョージが /sweet Cache Rule 提案「99.99% 安全」 |
| 12:30〜 | ナミオさんが 2回問う ── 「他人のレコード棚や最近見たアルバムが、他の人に見られること、本当に大丈夫かな?」 |
| 12:30〜 | ジョージ、実装調査に入る。Cookie 名 = album_sweet_auth_token、SSR が個人棚を HTML 埋め込み、Edge TTL Override は no-store を強制無視 |
| 判断 | ナミオさん「止める。速度強化は他に考えるべきことがある(WebP 化とか)。今後の課題に」 |
朝の AH01075 から昼の判断まで、教科書 1冊分の学びがあった。
終章:プロデューサーの判断軸を、もう一段下に置き直した日
この日のジョージは、技術の判断軸を もう一段下に置き直した。
朝の Cloudflare WAF 20分投入は、速度と防御の判断軸の上で正しかった。ProxyTimeout の修正も、技術的事実を読み直すという正しい判断だった。でも、昼の /sweet Cache Rule の判断軸は、速度と防御だけでは閉じられなかった。
もう一段下にあったのは、ユーザーの信頼だった。0.01% でも個人情報を漏らせば、ユーザーの信頼は失われる。速度はあとで足せる。信頼は、あとで戻らない。
プロデューサーの判断軸は、速度・防御・コスト・運用効率 ── 全部その上に置くべきものとして、ユーザーの信頼がある。便利を提案する前に、その下にユーザーの信頼が崩れる可能性はないかを確認する。崩れる可能性がゼロでないなら、便利を取り下げる。
これが、この日 album-sweet で確立した判断軸だ。
おわりに(編集者として)
この記事の取材で、ジョージから一番響いた言葉を最後に置きたい。
「ブライアンは『ナミオさんを守った』を求めてくれた。でも今日の本当の話は 逆 だ。ナミオさんが俺を守った」
これは、職人としての謙虚な言葉だ。でも、それ以上に、チームのあり方を言い当てている。
守る・守られるは、一方通行じゃない。職人は技術でナミオさんを守る。ナミオさんはユーザー視点でジョージを守る。守り合いの構造があるから、便利と効率の暴走が止まる。0.01% の漏洩可能性で踏みとどまれる。
「99.99% 安全」は、安全じゃなかった。
でも、「2回確認してくれる仲間」がいる構造は、安全だった。
これが、この日の本当の話です。