2026-10-14
HTTPSでも守れないものがある — セキュリティヘッダを図解する
TLSハンドシェイクや証明書チェーンの記事で、HTTPSが通信路をどう守っているかを見てきました。
ただ、HTTPSにしただけでは守れないものがあります。TLSが守るのは通信路であって、ブラウザがレスポンスをどう扱うかではないからです。
その穴を埋めるのが、レスポンスヘッダでブラウザに出す指示、いわゆるセキュリティヘッダです。この記事では主要な5つを、何を防ぐためのヘッダなのかという観点で図解します。
TLSが守る範囲と、守らない範囲
TLSが保証してくれるのは、サーバーとブラウザの間で盗聴・改ざんされないことと、相手が本物のサーバーであることです。
逆に言うと、データがブラウザに届いた後のことには関与しません。
- 届いたHTMLに紛れ込んだ悪意あるスクリプトを実行するかどうか
- 別のサイトが自分のページを
iframeで埋め込んでよいか - 外部サイトへ遷移するときに、元のURLをどこまで教えるか
こういった判断は全部ブラウザ任せです。しかもブラウザは、サーバーから指示がなければ寛容なデフォルトで動くことが多いんですよね。
セキュリティヘッダは、右側のブラウザの中に対して、サーバー側からこの条件で動いてくれと伝える仕組みです。
5つのヘッダ早見表
先に全体像を置いておきます。
| ヘッダ | 防ぎたいもの | 指示の中身 |
|---|---|---|
Strict-Transport-Security | HTTPへの降格(SSLストリップ)1 | このサイトは今後HTTPSでしか開くな |
X-Frame-Options | クリックジャッキング2 | このページをiframeに入れるな |
Content-Security-Policy | XSS(悪意あるスクリプトの実行) | 読み込んでよいリソースの出所はここだけ |
X-Content-Type-Options | MIMEスニッフィング3 | Content-Typeを信じろ、中身から推測するな |
Referrer-Policy | URLに含まれる情報の漏洩 | 遷移先に渡すRefererを絞れ |
ここから1つずつ見ていきます。
Strict-Transport-Security (HSTS)
TLSの記事の延長で、いちばん繋がりが強いのがこれです。
Strict-Transport-Security: max-age=31536000; includeSubDomains
HTTPSにしていても、最初の1回が危ない
ユーザーがアドレスバーにexample.comとだけ打つと、ブラウザはまずhttp://でアクセスします。サーバーは301でhttps://に飛ばしてくれるのですが、この最初のHTTPリクエストは暗号化されていません。
同じネットワーク上の攻撃者がここに割り込めば、リダイレクトを握りつぶして、ユーザーを偽サイトや平文の通信に留め置けます。これがSSLストリップです。
HSTSで何が変わるか
HSTSのヘッダを一度受け取ると、ブラウザはmax-ageで指定された秒数のあいだ、そのドメインへのアクセスをURLがhttpでも内部でhttpsに書き換えてから送ります。
つまり2回目以降は、平文のリクエストがそもそもネットワークに出ません。リダイレクトを待たずに済むので、割り込む隙もなくなります。
弱点と注意点
HSTSには弱点がひとつあって、最初の1回は保護されないことです。ヘッダを受け取るまでブラウザは何も知らないので。
これを埋めるのがプリロードリストで、ブラウザ自体にHSTS対象ドメインの一覧を内蔵してもらう仕組みです。登録にはpreloadディレクティブ付きのヘッダが必要です。
プリロード登録とは
自分のドメインを、ブラウザに最初から埋め込まれているHSTS対象ドメインの一覧に載せてもらうことです。
登録はhstspreload.orgから申請します。条件を満たしたドメインは、ブラウザの次のリリースに組み込まれる形で反映されます。Chromeの場合、安定版に載るまで数か月かかります。
ヘッダのpreloadは、この一覧に載せることへの同意を示す印で、付けただけでは何も起きません。
一度載ると、解除もブラウザのリリース待ちになります。
ただ、includeSubDomainsとpreloadは軽い気持ちで付けないほうがいいです。
includeSubDomainsは全サブドメインがHTTPS対応していないと、HTTPでしか動かないサブドメインが開けなくなる- 一度ブラウザに覚えさせた
max-ageは、サーバー側からは取り消せない - プリロードリストからの削除は、各ブラウザのリリースを待つので時間がかかる
最初は短いmax-ageで始めて、問題が出ないことを確認しながら延ばしていくのが無難だと思います。
X-Frame-Options
X-Frame-Options: DENY
クリックジャッキング
攻撃者のページが、正規サイトのページを透明なiframeで重ねて表示します。ユーザーは攻撃者のページのボタンを押したつもりで、実際には下にある正規サイトの購入ボタンや削除ボタンを押している、という攻撃です。
値
X-Frame-Optionsの値は2つです。
DENY: どこからもiframeに入れさせないSAMEORIGIN: 同じオリジンからなら許可
Content-Security-Policy (CSP)
セキュリティヘッダの中で、威力も面倒くささも一番大きいのがこれです。
Content-Security-Policy: default-src 'self'; script-src 'self'; frame-ancestors 'none'
何をするヘッダか
XSS4は、入力のエスケープ漏れなどでページに紛れ込んだ<script>が実行されてしまう攻撃です。
エスケープで防ぐのが本筋ですが、1か所でも漏れたら終わりです。CSPは漏れた場合の二重の防壁として、ブラウザ側に実行・読み込みを許可するリソースの出所を教えます。
攻撃者が<script src="https://evil.example/x.js">を差し込めたとしても、script-src 'self'ならブラウザが読み込みを拒否します。
ブラウザはスクリプトを取得する前に、そのURLのオリジンをCSPの許可リストと照合します。evil.exampleは'self'(自分と同じオリジン)に当てはまらないので、リクエストを送ることなくブロックされます。
判定するのはブラウザで、基準になるのはサーバーが送ったCSPヘッダです。攻撃者はHTMLにタグを差し込めても、このヘッダまでは書き換えられません。
主なディレクティブ
script-src/style-src/img-src/connect-src: 種別ごとの許可元default-src: 上のような種別ごとの指定に当てはまらない場合に適用されるframe-ancestors: このページを埋め込んでよい親ページ(前述のX-Frame-Optionsの後継)
'self'は自分と同じオリジン、'none'は一切許可しない、という意味です。
frame-ancestorsは、前の節のX-Frame-Optionsをより細かく制御できるようにした後継です。frame-ancestors 'none'はDENY、frame-ancestors 'self'はSAMEORIGINに相当し、さらに特定のオリジンだけを許可することもできます。両方が指定されたら、対応しているブラウザではframe-ancestorsが優先されます。
とはいえ古いブラウザも考えて、今も両方付けておくのが一般的なようです。
Content-Security-Policy: default-src 'self'; img-src 'self' https://images.example.com; frame-ancestors 'none'
| ディレクティブ | 対象 | 実際に使われる指定 | 許可される出所 |
|---|---|---|---|
script-src | スクリプト | default-src(個別指定なし) | 自分だけ |
style-src | CSS | default-src(個別指定なし) | 自分だけ |
img-src | 画像 | img-src(個別指定あり) | 自分とimages.example.com |
connect-src | fetchなどの通信先 | default-src(個別指定なし) | 自分だけ |
frame-ancestors | このページを埋め込む親ページ | frame-ancestors(個別指定あり) | どこからも不可 |
img-srcとframe-ancestorsは専用の指定があるので、そちらが使われ、default-srcは見られません。それ以外は専用の指定がないので、default-src 'self'が適用されます。
専用のディレクティブを書いた種別では、default-srcは足し合わせではなく無視されます。script-src https://cdn.example.comとだけ書くと、自分のオリジンのスクリプトまで許可されなくなる点に注意が必要です。
面倒なのはインラインスクリプト
CSPを真面目に設定すると、まずインラインの<script>やonclick="..."が全部動かなくなります。インラインスクリプトはXSSの主な入口なので、それを止めるのがCSPの狙いの中心だからです。
ただ、攻撃者が差し込んだインラインスクリプトと、自分で書いたインラインスクリプトは、HTML上では見分けがつきません。どうしても必要なら、自分のものにだけ目印を付けて許可します。目印の付け方は2通りです。
nonce
サーバーがリクエストのたびにランダムな文字列を作り、CSPヘッダと、許可したい<script>タグの両方に入れます。
Content-Security-Policy: script-src 'nonce-r4nd0m123'
<script nonce="r4nd0m123">console.log("自分で書いたスクリプト")</script>
<script>alert("攻撃者が差し込んだスクリプト")</script>
ブラウザは、ヘッダと同じ値のnonce属性を持つタグだけを実行します。上の例では、1つ目は実行され、2つ目はブロックされます。
攻撃者はページを読み込む前に、そのリクエストの値を知ることができません。値を使い回すと意味がなくなるので、リクエストごとに生成するのが条件です。
ハッシュ
スクリプトの中身から計算したハッシュ値を、ヘッダに書いておく方法です。
Content-Security-Policy: script-src 'sha256-abc123...'
ブラウザはインラインスクリプトの中身のハッシュを計算して、ヘッダの値と一致したときだけ実行します。1文字でも違えば別のハッシュになるので、中身を書き換えられたり、別のスクリプトを差し込まれたりしても通りません。
中身が決まっている静的なスクリプトに向いていて、HTMLを動的に生成できるなら、nonceのほうが扱いやすいです。
フレームワークが勝手にインラインスクリプトを吐く場合は、ここで詰まりやすいところです。
いきなり本番でブロックすると画面が壊れるので、まずContent-Security-Policy-Report-Onlyで違反をレポートだけさせて、何が引っかかるかを見るのが定石です。
X-Content-Type-Options
X-Content-Type-Options: nosniff
これは設定できる値がnosniffの1つだけです。
ブラウザは、サーバーが返したContent-Typeが怪しいとき、中身を見て種類を推測する(スニッフィングする)ことがあります。たとえばユーザーがアップロードした画像として配信されているファイルの中身が実はJavaScriptだと、ブラウザが推測してスクリプトとして実行してしまう、という事故が起こりえます。
nosniffを付けると、ブラウザは宣言されたContent-Typeを信じて、推測しなくなります。スクリプトとして読み込む場所にtext/javascript以外のものが来たらブロックされます。
付けるデメリットはほぼなく、Content-Typeを正しく返していれば何も壊れません。逆にContent-Typeの指定が雑だったサイトでは、これを入れた瞬間に動かなくなる箇所が見つかります。それはそれで有益な発見です。
Referrer-Policy
Referrer-Policy: strict-origin-when-cross-origin
ブラウザはリンクを辿るとき、遷移元のURLをRefererヘッダで遷移先に伝えます。
困るのは、URLにパスやクエリが含まれている場合です。たとえばパスワードリセット用のトークンがURLに入っているページから外部サイトへのリンクを踏むと、そのトークン付きURLが外部サイトのログに残りえます。
Referrer-Policyで、Refererにどこまで含めるかを指定します。指定できる値は8種類あります。
| 値 | 送られるReferer |
|---|---|
no-referrer | 一切送らない |
same-origin | 同じオリジンのときだけフルURLを送る |
origin | 常にオリジンだけ |
strict-origin | 常にオリジンだけ。ただしHTTPSからHTTPへの降格では送らない |
origin-when-cross-origin | 同じオリジンならフルURL、別オリジンならオリジンだけ |
strict-origin-when-cross-origin | 上に加えて、HTTPSからHTTPへの降格では送らない |
no-referrer-when-downgrade | 降格以外はフルURLを送る |
unsafe-url | 常にフルURLを送る |
下に行くほど緩く、unsafe-urlは外部サイトにもクエリ付きのURLがそのまま渡ります。
このうち、例に挙げたstrict-origin-when-cross-originの挙動は次の通りです。
| 遷移先 | 送られるReferer |
|---|---|
| 同じオリジン | フルURL(パス・クエリ込み) |
| 別オリジン (HTTPS→HTTPS) | オリジンのみ(https://example.com/) |
| HTTPSからHTTPへの降格 | 送らない |
最近のブラウザはこれがデフォルトの挙動になっています。ただ、ヘッダで明示しておけばブラウザの実装差に左右されませんし、意図が設定として残ります。デフォルトでたまたま安全より明示して安全のほうが、後から読んだ人が助かります。
設定する場所
どれもレスポンスヘッダなので、設定場所はリバースプロキシでもアプリケーションでも構いません。たとえばNext.jsなら、next.config.tsのheaders()で全ページに付けられます。
const nextConfig: NextConfig = {
async headers() {
return [
{
source: "/(.*)",
headers: [
{ key: "Strict-Transport-Security", value: "max-age=31536000" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
],
},
];
},
};
CSPだけは、先ほど書いた通りインラインスクリプトの扱いが絡むので、上の4つと同じ気軽さでは入れられません。
付いたかどうかはcurl -I https://example.comでレスポンスヘッダを見れば確認できます。
順番をつけるなら
全部を一気に入れる必要はないと思っています。
X-Content-Type-Options、Referrer-Policy、X-Frame-Optionsは影響が小さいので、まず入れる- HSTSは短い
max-ageから始めて、様子を見ながら延ばす - CSPは
Report-Onlyで違反を洗い出してから、少しずつ厳しくする
CSPの運用は自分もまだ手探りな部分が多いので、うまい進め方をご存じの方がいれば教えてもらえると嬉しいです。
参照
- Strict-Transport-Security - MDN
- Content-Security-Policy - MDN
- X-Content-Type-Options - MDN
- X-Frame-Options - MDN
- Referrer-Policy - MDN
- HSTS Preload List Submission