http:// と打ったのに、なぜブラウザはサーバーに聞く前に https:// へ書き換えるのか?
Rails の本番設定で config.force_ssl = true を見たことがあるはずだ。HTTP で来たリクエストを HTTPS へリダイレクトする設定だと理解していると、DevTools の Network タブに出る「307 Internal Redirect」が説明できない。サーバーは 301 を返す設定なのに、ブラウザは通信すらせずに URL を書き換えている。リダイレクトがあれば十分なはずなのに、なぜブラウザ側にもう一段の仕組みが要るのだろうか。
- HTTP→HTTPS のリダイレクトだけでは、最初の平文リクエストが中間者に横取りされ、HTTPS に上がる機会そのものを潰される。
- HSTS は「このホストには HTTPS でしか接続するな」という約束をブラウザに記憶させ、以後は送信前に URL を https:// へ書き換えさせる仕組みだ。
- 約束は max-age の期間ブラウザ側に残り、サーバーから簡単には取り消せない。includeSubDomains と preload は効果が大きいぶん、入れる前の確認が要る。
301 リダイレクトでは最初の 1 回が守れない
ユーザーがアドレスバーに example.com とだけ打つか、古い http:// のリンクを踏むと、最初のリクエストは暗号化されていない HTTP で飛ぶ。サーバーはそこで 301 Moved Permanently と Location: https://example.com/ を返し、ブラウザは HTTPS で入り直す。普段はこれで問題なく動く。
問題は、その 301 自体が平文で運ばれることだ。中間者攻撃(MITM)とは、通信経路の途中に入り込んだ攻撃者が通信を盗み見たり書き換えたりする攻撃を指す。公衆 Wi-Fi のアクセスポイントを攻撃者が握っていれば、301 をユーザーに渡さず、自分はサーバーと HTTPS で話しながらユーザーとは HTTP のまま中継し続けられる。この手口を SSL ストリッピングと呼ぶ。ユーザーの画面には見慣れたログインページが出るが、URL は http:// のままで、入力したパスワードは攻撃者に平文で届く。
サーバー側でいくら HTTPS を強制しても、この攻撃は防げない。サーバーが 301 を返す前の段階、つまりブラウザが最初の平文リクエストを送った時点で勝負が決まるからだ。守るには、ブラウザ自身が「このホストに平文を送ってはいけない」と最初から知っている必要がある。
HSTS ヘッダーがブラウザに覚えさせること
HSTS(HTTP Strict Transport Security)は、サーバーが HTTPS のレスポンスに付けるヘッダーで、ブラウザに「このホストは HTTPS 専用だ」と記憶させる仕組みだ。RFC 6797 で標準化されている。
Strict-Transport-Security: max-age=31536000; includeSubDomains
ヘッダーを受け取ったブラウザは、ホスト名と有効期限を手元に保存する。以後そのホストへの http:// の URL は、ネットワークに出る前に https:// へ書き換えられる(ポート 80 の明示は 443 に置き換わる)。Chrome の DevTools に出る 307 Internal Redirect はこの書き換えの記録で、実際にはサーバーと一切通信していない。レスポンスに付く Non-Authoritative-Reason: HSTS も、サーバーではなくブラウザが付けたものだ。
【初回】ブラウザはまだ何も知らない ブラウザ サーバー │ GET http://example.com ────▶ │ │ ◀──── 301 Location: https:// │ │ ↑ ここが平文。横取りされうる │ GET https://(TLS)────────▶ │ │ ◀──── 200 + Strict-Transport-│ │ Security: max-age=… │ ▼ 保存: example.com は HTTPS 専用 【2 回目以降】 │ http://example.com と入力 ├▶ 送信前に https:// へ書換え │ (307 Internal Redirect) │ GET https://(TLS)────────▶ │ ▼ 平文のリクエストは一度も出ない
仕様には、攻撃者に悪用されないための制約が 2 つ組み込まれている。1 つ目は、HTTP で届いた HSTS ヘッダーをブラウザが無視することだ。平文のヘッダーを信じると、中間者が max-age=0 を差し込んで約束を消したり、HTTPS に対応していないホストに HSTS を付けて閲覧不能にしたりできてしまう。2 つ目は、HSTS ホストで証明書エラーが起きたとき、ブラウザが「危険を承知で続行」のボタンを出さないことだ。ユーザーがクリックで突破できるなら、攻撃者は偽の証明書を見せて押させれば済む。
取引先の銀行から書留で「今後、当行からの通知は必ず書留で送ります。普通郵便で届いたものは偽物です」という手紙が届き、それを手帳に書き写しておくようなものだ。以後は普通郵便で来た「銀行からの通知」を、中身を読む前に捨てられる。ただし、この約束を普通郵便で受け取っても信じてはいけない(偽物が「書留ルールは廃止しました」と送ってこられるため)。そして、初めて取引する前に届く手紙は守れない。この弱点を埋めるのが、手帳に最初から印刷されている取引先リスト、つまり次に扱うプリロードリストだ。
3 つのディレクティブ、変えるとどうなるか
max-age は約束を覚えておく秒数で、ブラウザはヘッダーを受け取るたびに「現在時刻 + max-age」で期限を更新する。毎日アクセスするユーザーにとっては期限が実質的に来ない。逆に言えば、一度長い値を配ると、ヘッダーを外しても既に記憶したブラウザは期限まで HTTPS を強制し続ける。解除するには HTTPS で max-age=0 を返し、各ブラウザに再訪問してもらうしかない。HTTP 経由では解除できない設計なので、HTTPS を止めてから解除しようとしても遅い。
includeSubDomains を付けると、約束がすべてのサブドメインに及ぶ。example.com に付けた場合、社内向けに HTTP だけで動かしていた http://legacy.example.com が、そのブラウザからは開けなくなる。ここで多くの人の予想を裏切るのが Rails の既定値だ。config.force_ssl = true だけで HSTS が有効になり、既定は有効期限 2 年・サブドメイン適用あり・preload なしになっている。
# config/environments/production.rb
config.force_ssl = true
# 既定: HSTS 有効 / expires 2 年 / subdomains: true
config.ssl_options = {
hsts: { expires: 1.day, subdomains: false }
}
# 導入初期は短い期限で様子を見て、問題なければ延ばす
Rails で HSTS をやめたいときは hsts: false を使う。Rails のドキュメントによると、これはヘッダーを出さないのではなく expires: 0(つまり max-age=0)を送る指定だ。ヘッダーを消すだけでは、既に記憶したブラウザは元の期限まで約束を守り続けるからである。
preload は、次のセクションで扱うプリロードリストへの登録を申請する意思表示だ。付けただけでは何も起きないが、付けた状態で申請すると取り消しに時間がかかる。
初回の穴を埋めるプリロードリスト
HSTS ヘッダーは、一度 HTTPS でアクセスしたあとにしか効かない。初めて訪れるユーザーや、記憶が期限切れになったユーザーの最初のリクエストは、相変わらず平文で飛ぶ。RFC 6797 もこの初回の弱点を認めており、対策として「事前に配布されたリスト」を挙げている。
その実装が HSTS プリロードリストだ。ブラウザのバイナリに「HTTPS 専用のドメイン一覧」を焼き込んでおき、初回から書き換えを効かせる。Google が運営する hstspreload.org で申請でき、主な条件は、HTTPS で返すヘッダーが max-age 1 年(31536000 秒)以上で includeSubDomains と preload を含むこと、すべてのサブドメインが HTTPS で動いていることだ。リストはドメイン単位だけでなく TLD 単位でも登録でき、Google は 2017 年に .dev などの TLD を丸ごと登録した。.dev のドメインは、ヘッダーを一度も返していなくても最初から HTTPS 必須になる。
プリロードは強力なぶん、取り消しが重い。hstspreload.org 自身が、リストからの削除は可能だが Chrome のアップデートとしてユーザーに届くまで数か月かかると注意している。ブラウザに焼き込まれた情報なので、サーバー側で max-age=0 を返しても消せない。
よくあるのは、本番ドメインで force_ssl を有効にした結果、同じ親ドメイン配下の HTTP しか話さない検証環境や社内ツールが「なぜか自分のブラウザだけ開けない」状態になるケースだ。原因は Rails 既定の includeSubDomains で、サーバーの設定をいくら見ても見つからない。Chrome なら chrome://net-internals/#hsts でドメインを入力すると、そのブラウザが記憶している HSTS の状態を照会・削除できる。新規に HSTS を導入するときは、短い max-age から始め、サブドメインの棚卸しを終えてから期限を延ばし、preload は最後に検討する順番が安全だ。
curl は既定では HSTS を記憶しないが、--hsts オプション(curl 7.77.0 以降で正式サポート)でキャッシュファイルを持たせると、ブラウザと同じ書き換えを観察できる。まず HTTP では 301 だけが返り、HSTS ヘッダーは HTTPS 側にしかないことを確かめ、次に記憶させてから http:// を叩く。
curl -sI http://github.com | grep -iE '^(HTTP|location|strict)' curl -sI https://github.com | grep -iE '^(HTTP|strict)' rm -f /tmp/hsts.txt curl -s -o /dev/null --hsts /tmp/hsts.txt https://github.com cat /tmp/hsts.txt curl -sv -o /dev/null --hsts /tmp/hsts.txt http://github.com 2>&1 \ | grep -iE 'hsts|Connected to|^> GET'
1 本目は 301 と Location だけで strict-transport-security は出ず、2 本目に初めて現れるはずだ。/tmp/hsts.txt にはホスト名と有効期限が書かれ、先頭のドット(.github.com)は includeSubDomains の印になる。最後のコマンドでは Switched from HTTP to HTTPS due to HSTS と表示され、接続先がポート 443 になる。http:// と書いたのに、平文のリクエストは一度も送られていない。
- HTTP→HTTPS の 301 リダイレクトがあれば HSTS は不要 — 301 は平文の最初のリクエストに対する応答なので、中間者に横取りされればユーザーは HTTPS に上がれない。HSTS は 2 回目以降、プリロードは初回から、平文のリクエスト自体を出させない。
- HSTS ヘッダーを設定から外せば HSTS は止まる — 既に記憶したブラウザは max-age の期限まで約束を守り続ける。止めるには HTTPS で
max-age=0を返す必要がある。 - HSTS は HTTP のレスポンスに付けても効く — ブラウザは HTTP で届いた HSTS ヘッダーを無視する。偽のヘッダーで約束を消されたり付けられたりしないための仕様だ。
- HSTS
- HTTPS のレスポンスヘッダーで、ホストを HTTPS 専用としてブラウザに一定期間記憶させる仕組み(RFC 6797)。
- 中間者攻撃(MITM)
- 通信経路の途中に入り込み、通信を盗み見たり書き換えたりする攻撃。
- SSL ストリッピング
- HTTPS へのリダイレクトを横取りし、ユーザーを HTTP のまま中継し続ける中間者攻撃。
- max-age
- HSTS の約束を記憶する秒数。ヘッダーを受け取るたびに期限が延びる。0 で記憶を削除する。
- includeSubDomains
- HSTS の約束をすべてのサブドメインにも適用させるディレクティブ。
- HSTS プリロードリスト
- ブラウザに最初から組み込まれた HTTPS 専用ドメインの一覧。初回アクセスから HTTPS を強制する。
- Strict-Transport-Security header | MDN — 各ディレクティブの意味、初回アクセスの流れ、解除方法が短くまとまっている。
- RFC 6797: HTTP Strict Transport Security (HSTS) — HTTP 経由のヘッダーを無視する理由や初回の弱点(14.6 節)など、設計判断の根拠が読める。
- ActionDispatch::SSL | Rails API —
force_sslの HSTS 既定値と、hsts: falseがmax-age=0を送る理由が書かれている。 - Broadening HSTS to secure more of the Web | Google Security Blog — TLD 単位でプリロードリストに登録する狙いを Google 自身が説明している。