この連載のツールは、なるべく入力をサーバーに送らないように作っている。セキュリティヘッダーチェッカー は、その例外である。入力された URL は、このツールのサーバーに届き、サーバーがその URL にアクセスする。
やりたいことは単純で、URL を入れたら、そのサイトが返す応答ヘッダーを調べるだけである。ブラウザはページを開くたびに応答ヘッダーを受け取っているのだから、ブラウザの中でできそうに思える。最初はそう考えていた。
セキュリティヘッダーとは
Web サーバーは、ページの本文と一緒に、ヘッダーと呼ばれる付帯情報を返す。そのうち、ブラウザに防御の動作を指示するものをまとめてセキュリティヘッダーと呼んでいる。「このサイトには今後 HTTPS でしか接続しないこと」(HSTS)、「このページが読み込んでよいスクリプトの出どころはここだけ」(CSP)、「このページを他のサイトの枠の中に表示しないこと」(X-Frame-Options)といった指示である。
ツールはこうしたヘッダーを 15 項目に分けて判定する。判定の中身より先に、そもそもヘッダーをどうやって手に入れるかでつまずく。
ブラウザから読めるヘッダーは限られている
ブラウザの JavaScript で別のサイトに fetch
を送ることはできる。ただし、応答を読めるかどうかは相手次第である。ブラウザは、あるサイトのページが別のサイトの応答を読むことを原則として禁じており、相手が
Access-Control-Allow-Origin
ヘッダーで読み取りを許したときだけ、応答をページに渡す。この取り決めを CORS と呼ぶ(MDN の解説)。
ふつうの Web サイトのトップページは、このヘッダーを付けていない。試しに example.com
のトップページの応答を見ても、Access-Control-Allow-Origin
は含まれていなかった。つまり、診断したいサイトの大半で、ブラウザは応答そのものを JavaScript
に渡さない。
仮に相手が読み取りを許していても、まだ足りない。CORS で読めるヘッダーは、Content-Type
や Cache-Control
など、仕様で安全と決められた数個に限られる。それ以外のヘッダーを読ませるには、相手が
Access-Control-Expose-Headers
で名前を列挙しなければならない。Cookie を設定する
Set-Cookie にいたっては、どう設定してもページの JavaScript からは読めない(Fetch Standard)。ところが、Cookie の属性はこのツールの判定項目の一つである。
リダイレクトも追えない。http:// の URL が https:// へ転送されているかは重要な判定項目である。ところがブラウザの fetch は、転送を自動で追わせると最後の応答しか返さず、転送のたびに止めるよう指定すると、ステータスもヘッダーも空の応答を返す。途中の応答が何を指示していたかは、どちらの方法でも読めない。
ブラウザが他のサイトの応答を隠すのは、利用者を守るためである。ページを開いただけで、利用者がログインしている別のサイトの中身を読まれては困る。この制限を外す手段がページの側にない以上、ブラウザの中だけでこのツールを作る道はなかった。
サーバーが代わりに取りに行く
サーバーから送る HTTP 要求には、CORS の制限がかからない。CORS はブラウザが利用者を守るための決まりで、サーバー同士の通信には関係しないからである。そこで、このツールは Cloudflare Workers(Cloudflare のネットワーク上で小さなプログラムを動かすサービス)で API を一つ動かしている。
ブラウザが URL を API に送ると、サーバーは次の順に動く。
- 入力 URL に GET 要求を送る。本文は読まずに捨て、ステータスとヘッダーだけを取っておく。
- 応答がリダイレクトなら、転送先を自分で読んで次の要求を送る。これを最大 10 回まで繰り返す。1 回の要求は 10 秒で打ち切る。
- https:// の URL が入力されていれば、http:// 側からも同じように辿り、HTTPS へ転送されるかを確かめる。
-
最後の応答に CORS のヘッダーがあるか、URL に
/api/を含んでいれば、偽の Origin を付けて同じ URL をもう一度取得する。送った Origin がそのまま許可として返ってくるかを見るためである。
HEAD 要求(本文を返さず、ヘッダーだけを返すよう求める要求)を使えば本文を捨てる手間は省ける。それでも GET にしたのは、HEAD を拒否するサイトや、HEAD と GET で違うヘッダーを返す構成があるためである。利用者のブラウザが実際に受け取るのは GET の応答なので、そちらに合わせた。
アクセス先のサイトから見ると、要求は Cloudflare のネットワークから届き、User-Agent には
otasuke-headers-checker/1.0
と名乗る。利用者の IP アドレスは相手に伝わらない。
他人の URL を取りに行くサーバーの危うさ
「入力された URL
にサーバーがアクセスする」機能は、使い方を誤ると攻撃の踏み台になる。たとえば
http://localhost/ や
http://192.168.0.1/
を入力されたら、サーバーは自分自身や、外からは届かないはずの内部ネットワークに要求を送ってしまう。この種の攻撃を
SSRF(Server-Side Request Forgery)と呼ぶ(OWASP の解説)。
対策として、要求を送る前に次のものを拒否している。
- http と https 以外の URL、ユーザー名やパスワードを含む URL、ポート番号を明示した URL
-
localhost、ドットを含まないホスト名、.localや.internalなどで終わるホスト名 -
プライベート IP アドレス(
10.0.0.0/8、192.168.0.0/16など)やループバックアドレスを直接書いた URL - ホスト名を DNS で引いた結果に、上のようなアドレスが 1 件でも含まれる URL
この検査は、リダイレクトの転送先ごとにやり直す。最初の URL が公開サイトでも、転送先が内部のアドレスなら途中で止まる。ブラウザ任せの自動追跡ではなく転送を自分で辿っているのは、この検査を挟むためでもある。
防げないものもある。DNS で安全なアドレスを確認してから実際に接続するまでのわずかな間に、DNS の答えを内部アドレスへ差し替えられると、検査をすり抜けうる。この隙間は、ツールのコードでは塞げていない。Cloudflare Workers の側にも接続先の制限はあるものの、内部アドレスに解決される名前をどう扱うかまでは、公開されている資料で確認できなかった。
もう一つの危うさは、ツールが他のサイトへの大量アクセスの道具に使われることである。同じ IP アドレスからの診断は、60 秒に 10 回までに制限した(Cloudflare の Rate Limiting を使っており、数え方はデータセンターごとの目安である)。
サーバーに届くもの、残るもの
サーバーを経由する以上、入力した URL はこのツールのサーバーに届く。ツールのコードは URL も診断結果も保存しない。一方で、配信に使っている Cloudflare のアクセスログや、エラー調査のためのログ(最大 7 日保存)には、API への要求の一部として入力 URL が一時的に記録されうる。プライバシーポリシーにもそう書いてある。
外から取りに行く方式には、見えないものもある。サーバーは Cookie
を持たずにアクセスするので、ログイン後のページのヘッダーは診断できない。本文を読まないので、HTML
の <meta> タグで設定した CSP も見えない。CDN
を使っているサイトでは、利用者のブラウザと同じく CDN を通ったあとの応答を見ることになる。
ブラウザの中で完結させたかった、とは今でも思う。ただ、ブラウザが他のサイトの応答を隠す仕組みは、このツールの利用者自身も守っている。隠されたものを読むためにサーバーを置き、そのサーバーが悪用されないよう、行き先と回数を絞る。入力をサーバーに送るツールを作るなら、この二つの対策は外せないと考えている。