HTTP セキュリティヘッダは、サーバが返すレスポンスに添える「ブラウザへの命令書」。「このサイトでは TLS を必須にしろ」「このスクリプト以外は実行するな」「iframe に埋め込ませるな」といった指示を、アプリケーションコードを一切触らずに加えられる。Same-Origin Policy (SOP) は クロスオリジンへの直接アクセスを止めるが、同一オリジン内に注入されたコード (XSS) や、自オリジンを iframe に押し込まれたとき (クリックジャッキング) には無力 — そこを埋めるのがこれらのヘッダだ。本稿は HSTS → CSP → クリックジャッキング → nosniff/Referrer → Permissions-Policy → クロスオリジン分離 → 検証と運用の順で、各ヘッダの「何を守るか」「どう壊れるか」を一本の流れで読み解く。
細部が多くて圧倒されがちだが、本質は次の 3 つだけ持ち帰ればいい。(1) セキュリティヘッダは 「ブラウザ側で動かす防御」。サーバから「こう振る舞え」と命令する。(2) アプリのコードを直さずに nginx の設定 1 行で追加できる。導入コストが軽い割に効果が大きい。(3) 一番重要なのは HSTS (TLS 強制) と CSP (スクリプト制御)。それ以外は補助。— ここを軸に各章を読めば、迷子にならない。
なぜブラウザに「ヘッダで命令」するのか #
ブラウザは古い HTML / 古いプロトコルにも互換性を保つように作られている。たとえばデフォルトでは HTTP のリンクをクリックすれば暗号化なしで通信するし、<script src="..."> で取ってきた JavaScript は実行するし、<iframe> で他サイトに埋め込まれるのも許す。これらは Web の機能としては正しいが、攻撃者から見ればそのまま使える攻撃面でもある。
セキュリティヘッダは、サーバ運営者が 「うちのサイトでは古い挙動を切ってくれ」とブラウザに通告する仕組み。サーバが守るのではなく、ブラウザに守らせる。だから対象が「ブラウザのユーザ」である Web アプリにしか効かず、curl や独自スクリプトは無視する。逆に言えば、ブラウザ経由で来る攻撃 (XSS / クリックジャッキング / MIME sniffing 等) に絞って絶大な効果がある。
普通のはがきの裏に 「未開封のまま転送禁止」「コピー禁止」と書いてあっても、配達員が読まなければ意味がない。HTTP セキュリティヘッダは ブラウザがちゃんと読んで従う「規約」。サーバが「TLS なしでは絶対繋ぐな」「このスクリプト以外実行するな」と指示し、現代のブラウザはこれに かなり厳密に従う。だから「サーバ側で何もしていない」アプリでも、ヘッダ 5 行追加するだけで防御層が一気に増える。
ヘッダで守れる攻撃を整理すると次のようになる。
| 攻撃 | 対応ヘッダ | 何を止めるか |
|---|---|---|
| HTTP downgrade / SSL stripping | Strict-Transport-Security |
プレーン HTTP への接続そのもの |
| XSS (反射型 / 格納型 / DOM) | Content-Security-Policy |
インラインスクリプトと外部スクリプトの実行 |
| クリックジャッキング | Content-Security-Policy: frame-ancestors / X-Frame-Options |
他オリジンの iframe 埋め込み |
| MIME sniffing による偽装ファイル実行 | X-Content-Type-Options: nosniff |
image/jpeg 装った JS の実行 |
| Referer 漏洩 | Referrer-Policy |
URL に含まれるトークン・パスの流出 |
| 過剰権限 (カメラ・マイク・支払い) | Permissions-Policy |
サードパーティが勝手に API を呼ぶこと |
| Spectre / Meltdown 系 | Cross-Origin-{Opener,Embedder,Resource}-Policy |
クロスオリジンの共有メモリ・ポインタ漏洩 |
これらは互いに直交していて、1 つでは不十分・全部入れて初めて完成する多層防御の構成要素になる。
HSTS — TLS を絶対条件にする #
HSTS (HTTP Strict Transport Security, RFC 6797) は、「このサイトには HTTP では絶対に接続するな」とブラウザに記憶させるヘッダ。発行されたブラウザは、ユーザが http://example.com/login と打っても、URL バーで http:// のリンクをクリックしても、サーバに問い合わせる前に https:// に書き換えて接続する。
HTTPS でアクセスしたサイトが「次回からも必ず HTTPS で来てね」とブラウザに付箋を貼るのが HSTS。次回以降、ユーザがうっかり `http://` と打っても、攻撃者が `Location: http://...` で誘導しても、ブラウザが先回りして HTTPS に書き換える。つまり downgrade 攻撃 (SSL stripping) を仕組みごと封じる。一度受け取った付箋は max-age の期間 (通常 1〜2 年) 保持され、その間は HTTP では絶対に出ない。
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
| ディレクティブ | 意味 |
|---|---|
max-age=63072000 |
このルールを覚えておく秒数。例は 2 年 (推奨: 最低 1 年) |
includeSubDomains |
*.example.com も全部対象にする |
preload |
ブラウザにバンドルされた preload リストへの登録を希望する宣言 |
preload はオプトイン式で、hstspreload.org に登録すると Chrome / Firefox / Safari にハードコードされ、そのサイトを一度も訪れていない新規ユーザにも HSTS が効く。逆に「一度登録すると外すのが非常に重い (リスト反映に数か月)」ため、本番で確実に HTTPS のみで運用する自信が出てから登録する。
(1) 一度発行した HSTS は max-age を 0 にした応答を新たに HTTPS で返さない限り消えない。証明書を失効したサイトに HSTS を効かせていると、ブラウザは「HTTPS でしか繋がない」のに「HTTPS は証明書エラー」になり、ユーザがアクセスする手段が消える。(2) `preload` 登録後は サブドメインも全部 HTTPS 化必須。社内ツールが HTTP で動いていた、というケースで一斉に死ぬ。導入は 短い max-age (例: 5 分) → 1 日 → 1 週間 → 1 年 → preload と段階的に上げる。
HPKP (Public-Key-Pins) という「証明書の公開鍵ピンニング」ヘッダも 2012 年頃に登場したが、誤設定でサイトが恒久的にアクセス不能になる事例が頻発し、Chrome 72 (2019) で削除済み。現在の標準は「証明書透明性 (CT) ログ + CAA レコード」で、ヘッダによる pinning は推奨されない。
CSP — スクリプト実行の許可リスト #
Content-Security-Policy (CSP) はセキュリティヘッダの中で最も強力かつ最も難しい。XSS や inline script 注入による攻撃を、「許可したものだけ実行する」というホワイトリスト方式で塞ぐ。攻撃者が <script> を注入できても、CSP が許可していないスクリプトはブラウザが実行を拒否する。
XSS の本質は サイトと同じオリジンで攻撃者のスクリプトが動いてしまうこと。CSP はサーバが 「うちで動かしていいのはこの URL のスクリプトと、この nonce 付きのものだけ」とブラウザに通告する仕組み。注入されたインライン `<script>alert(1)</script>` は nonce が無いので実行されずに静かに弾かれる。XSS の出力エスケープを忘れた箇所が 1 つ残っていても、CSP が二重の壁として効く。
代表的なディレクティブを並べる。
| ディレクティブ | 制御対象 | 例 |
|---|---|---|
default-src |
他で個別指定がないリソース全般 | 'self' |
script-src |
JavaScript の取得元 | 'self' 'nonce-abc123' |
style-src |
CSS の取得元 | 'self' 'unsafe-inline' |
img-src |
画像 | 'self' data: https: |
connect-src |
fetch / WebSocket / EventSource の接続先 | 'self' https://api.example.com |
font-src |
フォント | 'self' https://fonts.gstatic.com |
frame-src / child-src |
iframe で読み込めるオリジン | 'self' |
frame-ancestors |
自分が iframe に埋め込まれていいオリジン | 'self' |
form-action |
<form> の送信先 |
'self' |
base-uri |
<base> タグで設定可能な URL |
'self' |
object-src |
<object> / <embed> (Flash 等) |
'none' |
report-to / report-uri |
違反レポートの送信先 | https://example.com/csp-report |
最低限の現代的ベースラインはこうなる。
# 自オリジンの static アセットだけ許可、object はゼロ、frame-ancestors も自分だけ
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{毎リクエストランダム}';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
object-src 'none';
base-uri 'self';
frame-ancestors 'self';
report-uri /csp-reportポイントは nonce ベースにすること。'unsafe-inline' を使うと CSP の防御力が一気に落ちる ― インライン <script>...</script> を全部許してしまうので、XSS が刺さった瞬間に CSP は無いに等しくなる。代わりにサーバ側で毎リクエスト乱数 (nonce) を生成し、自分が出力したスクリプトタグだけに <script nonce="abc123"> を付けて識別する。攻撃者には正しい nonce が分からないので注入できない。
`'unsafe-inline'` はインライン script / インライン style を全許可、`'unsafe-eval'` は eval() / new Function() を許可するディレクティブ。どちらも CSP の 防御力をほぼゼロにする。Google Tag Manager 等の第三者タグが要求してくるケースが多いが、可能なら nonce / hash に移行する。両方付けた CSP は「ポリシーがあるだけで実質ノーガード」なので、Mozilla Observatory も A 評価を出さない。
段階導入には Content-Security-Policy-Report-Only を使う。同じ書式だが「ブロックせずに違反だけ報告する」モード。本番に出してアクセスログ的に違反を集め、'unsafe-inline' を外したときに何が壊れるかを把握してから本ポリシーに切り替える。新規導入時は必ず report-only から。
Trusted Types (require-trusted-types-for 'script') は CSP の上位機能で、element.innerHTML = userInput のようなそもそも危険な代入を型レベルで禁止する。Chromium 系のみだが、DOM Based XSS に対する究極の対策として大規模サイト (Gmail / Google 検索) で採用が進んでいる。
クリックジャッキング — X-Frame-Options と frame-ancestors #
クリックジャッキングは、攻撃者サイトに被害者サイトを <iframe> で透明に重ね、ユーザのクリックを別の場所のボタンに「乗っ取る」攻撃。被害者は「いいね」ボタンを押したつもりが、裏でログアウトや送金ボタンを押している、というシナリオ。
攻撃者は「クリックすると当選」ボタンを置いた偽サイトを用意し、その上に opacity:0 の iframe で銀行サイトの「送金実行」ボタンを ピクセル単位でぴったり重ねる。ユーザが「当選」を押すと、実際には iframe 越しに銀行の送金ボタンを押したことになる。iframe に埋め込めないようにすればこの攻撃は成立しないので、ヘッダで「iframe 埋め込み禁止」を宣言する。
埋め込みを止める方法は 2 系統ある。
X-Frame-Options: DENY # どこからも iframe 不可
X-Frame-Options: SAMEORIGIN # 自オリジンの iframe だけ可
Content-Security-Policy: frame-ancestors 'none'
Content-Security-Policy: frame-ancestors 'self' https://partner.example.com
X-Frame-Options は古いヘッダで、DENY / SAMEORIGIN の 2 値しか取れない。CSP の frame-ancestors はその上位互換で、複数オリジンを許可リスト形式で書ける。両方付けてもよいが、frame-ancestors が指定されていれば現代のブラウザは X-Frame-Options を無視する。古いブラウザ互換のために両方入れる運用が安全。
| 用途 | 推奨設定 |
|---|---|
| 一切埋め込ませない (銀行・管理画面) | X-Frame-Options: DENY + frame-ancestors 'none' |
| 自社サイト内でのみ埋め込む | X-Frame-Options: SAMEORIGIN + frame-ancestors 'self' |
| 特定パートナーだけに許可 | frame-ancestors 'self' https://partner.example.com (XFO は付けないか SAMEORIGIN) |
ALLOW-FROM という第三の値もあったが、Chromium / Firefox いずれも実装せず事実上消滅。「特定オリジンだけに iframe を許す」用途は frame-ancestors を使うしかない。
MIME sniffing と Referrer の制御 #
ここから先は地味だが必須のヘッダ群。攻撃の入口を小さく削っていく類のものなので、目立たないが入れない理由がない。
X-Content-Type-Options: nosniff #
ブラウザは古い慣習で、サーバが返した Content-Type が「明らかに違うんじゃ?」と判断した場合に勝手にバイナリを覗いて MIME を推測する (MIME sniffing)。たとえば image/jpeg だが中身が JavaScript で始まっている、というファイルがあると、文脈次第で JS として実行してしまう。これは攻撃者にとっておいしい — 画像アップロード機能のあるサイトに JS を画像として上げ、<script src> で読み込ませる形の攻撃が成立する。
X-Content-Type-Options: nosniff
このヘッダがあれば、ブラウザは sniffing を完全に止め、Content-Type を額面通りに信じる。セットしない理由は存在しない。
Referrer-Policy #
<a href> クリックで遷移したとき、遷移先には Referer ヘッダでどの URL から来たかが送られる。URL にトークンが入っていたり、社内ツールのパスがバレるのを防ぐにはこのヘッダで漏洩量を制御する。
Referrer-Policy: strict-origin-when-cross-origin
| 値 | 何が送られるか |
|---|---|
no-referrer |
一切送らない |
same-origin |
同一オリジンへのみフル URL を送る |
strict-origin |
クロスオリジンにはオリジン (https://example.com) のみ |
strict-origin-when-cross-origin |
同一オリジンはフル URL、クロスオリジンはオリジンのみ (現代の主要ブラウザのデフォルト) |
unsafe-url |
常にフル URL を送る (非推奨) |
現代ブラウザはこのヘッダ未指定なら strict-origin-when-cross-origin をデフォルトとして使うので未指定でもそこそこ安全だが、明示する方が将来の挙動変化に強い。URL にセッショントークンや 1 回限りリンクが乗っているサービスは no-referrer 寄りに振る。
Permissions-Policy — 強い API の絞り込み #
カメラ・マイク・位置情報・支払い・センサーなど、強い権限を持つ Web API を、自オリジンや特定パートナーだけに許可するヘッダ。前身は Feature-Policy で、構文を整理して 2020 年に改名された。
Permissions-Policy: camera=(), microphone=(), geolocation=(self), payment=(self "https://pay.example.com")
| 機能 | 例 | 用途 |
|---|---|---|
camera |
() で全禁止、(self) で自オリジンのみ |
サードパーティタグが勝手にカメラを起動しない |
microphone |
同上 | 同上 |
geolocation |
(self) |
広告 SDK が位置情報を抜くのを防止 |
payment |
(self "https://pay.example.com") |
決済 API を自社と決済代行ドメインに限定 |
usb / bluetooth / serial |
() |
WebUSB 等の機器アクセスを完全禁止 |
interest-cohort |
() |
Google の FLoC を無効化する宣言 (歴史的) |
これは「自社が使うつもりのない強い API はそもそも誰にも使わせない」という保守的設定をデフォルトにする発想。3rd party の広告タグや解析タグが navigator.mediaDevices.getUserMedia() を呼んできても、Permissions-Policy が camera=() ならブラウザは即座に拒否する。
クロスオリジン分離 — Spectre 後の世界 (COOP / COEP / CORP) #
2018 年の Spectre / Meltdown 脆弱性以降、ブラウザは「同一プロセス内の他オリジンのメモリも信用しない」前提に書き換えられた。SharedArrayBuffer のような高精度タイマーや共有メモリは、ブラウザがオリジンを別プロセスに隔離 (cross-origin isolated) できたときだけ使える、という制約が加わった。これを宣言するのが COOP / COEP / CORP の 3 兄弟。
| ヘッダ | 意味 | 代表値 |
|---|---|---|
| COOP (Cross-Origin-Opener-Policy) | window.opener でクロスオリジン窓を覗かれない |
same-origin |
| COEP (Cross-Origin-Embedder-Policy) | 自ページに埋め込むサブリソースに CORP / CORS 同意を強制 | require-corp |
| CORP (Cross-Origin-Resource-Policy) | サブリソース側が「誰に埋め込まれていいか」を宣言 | same-origin / same-site / cross-origin |
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Resource-Policy: same-origin
古いブラウザは、複数のオリジンを 1 つのプロセスに同居させて動かしていた (メモリ節約のため)。Spectre はその同居メモリを覗き見る攻撃。対策は 「うちのオリジンを別の部屋 (プロセス) に隔離してくれ」とブラウザに頼むこと。COOP / COEP / CORP の 3 つを揃えると crossOriginIsolated === true になり、ブラウザは そのページを別プロセスで動かし、SharedArrayBuffer や performance.now() の高精度タイマーが解禁される。
実用上、SharedArrayBuffer を使わないサイトでも COOP: same-origin だけは入れておく価値がある — window.opener 経由のクロスオリジン制御 ("tabnabbing") を遮断できるので、target="_blank" 多用サイトでは効く。COEP は外部 CDN を読みやすくしているサイトでは全部 require-corp 対応に直す必要があり、導入コストが大きい。OAuth ポップアップが必要なら COOP は same-origin-allow-popups にしてポップアップ通信は許す、という妥協値もある。
検証と運用 — 何を入れて、何が効いているか #
設定したつもりで効いていないヘッダは、攻撃のとき「あると思ってた防壁が無い」という最悪の結果を生む。導入したら必ず外部から検証する。
curl で生のヘッダを見る #
# -s: 進捗を抑制 / -I: HEAD リクエスト
$ curl -sI https://example.com/ | grep -iE 'strict-transport|content-security|x-frame|x-content-type|referrer|permissions|cross-origin'
strict-transport-security: max-age=63072000; includeSubDomains; preload
content-security-policy: default-src 'self'; script-src 'self' 'nonce-...'; ...
x-frame-options: DENY
x-content-type-options: nosniff
referrer-policy: strict-origin-when-cross-origin
permissions-policy: camera=(), microphone=(), geolocation=(self)
cross-origin-opener-policy: same-origin外部スキャナで採点する #
securityheaders.com (Scott Helme 運営) は無料で A+ 〜 F 評価を出す軽量チェッカー。Mozilla Observatory (observatory.mozilla.org) はもう少し深く、TLS 設定や Cookie 属性まで含めた総合採点を出す。両者で A 以上が目安。
nginx での実装例 #
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(self)" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
# CSP は nonce が要るのでアプリ側で組み立てて返す方が現実的末尾の always を忘れるとエラーページ (4xx / 5xx) でヘッダが付かない。攻撃時に出るのは大抵エラー応答なので、必ず always を付ける。
Laravel での実装例 #
ミドルウェアでまとめて付ける。nonce を毎リクエスト生成して CSP に埋める。
// 各レスポンスにセキュリティヘッダを付与
public function handle($request, Closure $next)
{
$nonce = base64_encode(random_bytes(16));
view()->share('cspNonce', $nonce); // blade で {{ $cspNonce }} 参照
$response = $next($request);
$response->headers->set('Strict-Transport-Security',
'max-age=63072000; includeSubDomains; preload');
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('X-Frame-Options', 'SAMEORIGIN');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set('Content-Security-Policy',
"default-src 'self'; script-src 'self' 'nonce-{$nonce}'; "
. "style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; "
. "object-src 'none'; base-uri 'self'; frame-ancestors 'self'");
return $response;
}
導入順序の現実解 #
X-Content-Type-Options / X-Frame-Options / Referrer-Policy / HSTS (短い max-age から)。アプリの挙動を変えずに防御層が増える。Content-Security-Policy-Report-Only で違反だけ集める。インラインスクリプト / 外部スクリプトの依存をすべて洗い出し、`'unsafe-inline'` を外す道筋を作る。Content-Security-Policy 本ヘッダに昇格。nonce ベースに移行。Report-Only も並列で残し、新ポリシーの違反を継続収集する。max-age=31536000; includeSubDomains; preload を 1 年安定稼働させた後、`hstspreload.org` に登録。「ハードコード組」になる。CSP は 「XSS が起きてしまった後に被害を抑える」防御。出力エスケープが正しければ CSP は不要、ではなく、出力エスケープが 1 か所漏れたときの保険として CSP を入れる。逆に CSP を入れたから出力エスケープを怠っていい、という発想は本末転倒。アプリ層の正しさ → ブラウザ層のヘッダ防御の順番でしか、本当の安全は来ない。