「このアプリってセキュリティリスクない?」と AI に聞いた。1行目は「大きな穴はありません」。スクロールすると、「残るリスク」が4つ並んでいた。健康診断の表紙に「異常なし」、めくると要経過観察が4項目。
題材は、利用者が自分の API key を貼って使う、LLM で文章を訳すツール。GitHub Pages に置いた静的サイトで、サーバーが無いので key はブラウザの localStorage に入る。この記事は、その守りを AI エージェントと固めたときの会話ログから、筆者が問い返した場面と、問い返し損ねた場面を拾ったものだ。
表紙の「異常なし」をめくる
AI が挙げた「残るリスク」は次の4項目。
- key が localStorage に平文で残る
- 同じアカウントの Pages は
<owner>.github.ioという1つの origin を共有していて、別のリポジトリのページから key を読める - CSP が無いので、依存が乗っ取られると key をどこへでも送れる
- 貼った文章によるプロンプトインジェクション(軽微)
「大きな穴はない」は、この4項目を1行に畳んだ所見だった。嘘ではない。ただ、表紙だけ読んで帰ると、要経過観察はそのまま引き出しにしまわれる。
筆者が返したのは「対策できるものは全部対策したい。ドメインは自社ドメインのサブドメインを切れば良いなと思ってる」。所見は一瞬で作業の一覧に変わり、AI は CSP と、key をタブを閉じたら消す選択肢を実装し、独自ドメインへの切り替え手順まで書いた。仕事が速い。
「かなり防げる」が、書いた途端「送れない」になった
ログを読み返すと、CSP の評価が途中で一段上がっている。調べている段階では「外への持ち出しはかなり防げます」。実装の報告では「依存が乗っ取られても、key を外へ送れない」。書いた本人が、書き終えた瞬間に自分の対策を盛っていた。
不誠実だったわけではない。同じ報告で AI は「実ブラウザで CSP が自前のファイルを止めないかは未確認」と正直に書いている。未確認の札を1枚は貼ったのに、「送れない」には貼り忘れた。
そこでこの記事のために、AI 同士の紅白戦をやった。防御側の AI が書いたのと同じポリシーのページから、攻撃役の別の AI が、手元に立てたサーバーへダミーの key を送る。
| 送り方 | 結果 |
|---|---|
| fetch・WebSocket・sendBeacon・画像・prefetch・フォーム | CSP が止めた |
location.href での遷移・window.open | 届いた |
| WebRTC(TURN サーバーのユーザー名に key を入れる) | 届いた |
WebRTC だけは、受けるだけのサーバーには key が来ず、401 を返すと次の要求に載って届いた。本物の TURN サーバーは 401 を返すので、攻撃者は何もしなくていい。仕様にある webrtc 'block' も Chrome 153 では未実装で、Console に Unrecognized directive と出た。測ったのは Chrome 153 の headless と最小のページだけだが、勝敗ははっきりした。
裏口はほとんど閉まっていた。開いていたのは、ページ遷移という正面玄関と、WebRTC という通用口だ。
玄関に鍵は掛けられないので、最後に効くのは API 側の使用量の上限だ。
404 と5往復した話
独自ドメインに切り替え、DNS を直し、HTTPS も有効にした。それでもブラウザでは 404。AI の手元では 200。サイトは正常なのに、開けない。
| 筆者の返し | AI の次の仮説 |
|---|---|
| まだ 404 | Mac とブラウザに古い DNS の答えが残っている |
| 直し方やったけどだめだ | URL の打ち間違い、キャッシュ、ブラウザのセキュア DNS |
| Safari でも 404 | 打ち間違い、iCloud プライベートリレー |
| 404 画面のスクリーンショット | ブラウザが古い接続先を握り続けている。ほかの端末でも試すよう勧める |
| はじめて開いた端末(iPad)でも 404 | 原因を特定 |
AI の見立ては一貫していた。サイトは正常、問題はそちらのブラウザ。筆者は言われたとおりにキャッシュを消し、ブラウザを替え、そのたびに「まだ 404」を返す。4往復目、AI はほかの端末で開いてみるよう勧めた。開ければ、この Mac のブラウザだけの問題だとはっきりする、と。
勧めに従って、一度も開いたことのない iPad で開いた。404。キャッシュを持たないはずの端末なのに、だ。ブラウザ側の仮説は、ここで全部崩れた。
AI の説明はこうだ。設定が整う前に届いたリクエストへの 404 を、GitHub の CDN が圧縮版としてキャッシュしていた。ブラウザは必ず圧縮版を注文するので、どの端末でもそれが出てくる。同じ注文のはずが、AI の curl だけは圧縮なしという別メニューを頼んでいて、ひとりだけ 200 を食べていた。同じ commit で再デプロイすると、ブラウザと同じ注文でも 200 が返るようになった(AI の確認)。
どちらの確認も、自分の注文に対しては正しかった。人が返せるのは、条件を1つ変えた結果だ。
古い key は誰が消すのか
AI の切り替え手順には「古い URL を開いて key を削除してから切り替える」があった。切り替えると古い URL は新しいドメインへ 301 で転送され、古い origin のページはもう開けないからだ。筋は通っている。
ただ、これは作者のブラウザの話でしかない。利用者のブラウザに残った key は、作者には消せない。問い返すべき一言は「それ、利用者の分は誰が消すの?」で、筆者は会話の中でこれを言えなかった。気づいたのは、この記事のためにログを読み返したときだ。
答えは、起動時に古い origin の key を消し、新しい URL で貼り直すよう案内する版を、切り替え前に一度出すこと。独自ドメインが無ければ、そのツール専用の organization の <org>.github.io に移しても origin は分かれる。この場合も、移る前に key を消す版は要る。
問い返しの型
| AI の答え | 返す一言 |
|---|---|
| 大きな穴はありません | 残るリスクを重い順に全部。対策できるものは全部やる |
| これで送れません/防げます | 攻撃役を立てて実際に送らせ、届いたログを見せて |
| こちらでは 200 です | 別の端末でも出た。あなたの確認とブラウザの要求は何が違う? |
| 切り替え前に古い URL で消してください | それは誰のブラウザの話? 利用者の分は? |
実際に返したのは1行目の後半と3行目の前半で、残りは読み返してから足した。打率にすると2割5分。共通するのは、AI の結論の1文ではなく、その下にある前提を突くことだ。
まとめ
AI は調べるのも直すのも速く、結論を1行で言い切ってくれる。その1行は表紙で、中身は下にある。表紙をめくり、言い切りは攻撃役に試させ、AI の手元で再現しない不具合には条件を変えた結果を返す。エージェントを使った開発の進め方を外から整えたい場合は、AI開発支援で相談を受け付けている。



