きっかけは、社内のセキュリティ棚卸しで自社サイトのレスポンスヘッダを眺めていたときでした。
HSTS も X-Content-Type-Options も X-Frame-Options も、ちゃんと付いている。数か月前にシークレット管理まわりを整理したときに、CloudFront のレスポンスヘッダポリシーで一通り設定したものです。「ヘッダは揃っているな」と一度は安心しかけました。
そこで手が止まったのは、Content-Security-Policy が無いことに気づいたからです。
正直なところ、それまで私は「セキュリティヘッダ = HSTS や X-Frame-Options のセット」くらいの雑な括りで理解していました。でも改めて並べてみると、XSS(クロスサイトスクリプティング)に対して一番効くはずの CSP と、ブラウザ機能を絞る Permissions-Policy だけがすっぽり抜けている。しかもこの2つは、CloudFront のマネージド設定では「おまけ」扱いになりやすく、意識しないと付かないんですよね。
運用を考えると、これは放置すべきではないと考えました。ただ、静的サイトに CSP を入れる作業は、想像していたより一筋縄ではいきませんでした。
何が付いていて、何が付いていなかったか
まず現状の確認から。うちのサイトは Astro でビルドした静的 HTML を S3 に置き、CloudFront から配信する構成です。ヘッダは CloudFront の ResponseHeadersPolicy で宣言的に付けています。
# infra 側の CloudFront レスポンスヘッダポリシー(抜粋・簡略化)
SecurityHeadersConfig:
StrictTransportSecurity:
AccessControlMaxAgeSec: 63072000 # 2年
IncludeSubdomains: true
Preload: true
Override: true
ContentTypeOptions: # X-Content-Type-Options: nosniff
Override: true
FrameOptions: # X-Frame-Options: DENY
FrameOption: DENY
Override: true
ReferrerPolicy:
ReferrerPolicy: strict-origin-when-cross-origin
Override: true
# ← ここに ContentSecurityPolicy が無い
SecurityHeadersConfig という名前のブロックに書けるのは、HSTS・nosniff・X-Frame-Options・Referrer-Policy あたりの「値の選択肢が決まっている」ヘッダです。CSP はここには含められません。CSP を入れたいなら、同じポリシーの CustomHeadersConfig(任意ヘッダを名前と値で足す枠)に、自分で組み立てた文字列を書く必要があります。Permissions-Policy も同じ扱いです。
ここが最初の落とし穴でした。「セキュリティヘッダを設定した」という達成感が、実は一番大事な CSP を素通りさせていた。マネージドな枠に収まるものだけ付けて満足していた、という話です。
いきなり有効化したら、自分のサイトが動かなくなった
では書けばいい、と CSP 文字列を組み立て始めました。最初に試したのは、教科書どおりの厳しめの値です。
default-src 'self'; script-src 'self'; style-src 'self'; object-src 'none'
これをローカルのプレビュービルドに当てて開いた瞬間、サイトがまともに動かなくなりました。
ヘッダーのハンバーガーメニューが開かない。ページのスクロール連動アニメーションは動くのに、フォーム周りが無反応。開発者ツールのコンソールは CSP 違反の赤いログで埋まっていました。
犯人を一つずつ潰していくと、うちのサイトが思っていた以上に「インラインスクリプト」と「外部ドメイン」に依存していることが分かってきました。整理するとこうです。
| 止まった要素 | 正体 | なぜ止まったか | 対策 |
|---|---|---|---|
| メニュー・UI挙動 | Alpine.js(jsdelivr の CDN から読み込み) | script-src ‘self’ に外部ドメインが含まれない | CDN ドメインを許可リストに追加 |
| アクセス計測 | gtag.js(googletagmanager から読み込み)+ 初期化のインラインスクリプト | 外部ドメイン未許可+インラインが ‘self’ で弾かれる | ドメイン許可+インラインはハッシュ許可 |
| 問い合わせフォーム | 送信先URLを埋め込むインラインスクリプト | インラインが ‘self’ で弾かれる | インラインはハッシュ許可 |
| スクロールアニメーション | 通常の <script>(Astro がビルド時に外部ファイル化) | 実は止まっていない(/_astro/ 配下の自ドメイン扱い) | ‘self’ で通る(対応不要) |
最後の行が地味に重要でした。Astro は is:inline を付けない通常の <script> を、ビルド時にハッシュ付きファイル名で /_astro/ 配下に切り出してくれます。つまり自ドメイン配信の外部ファイルになるので、script-src 'self' でそのまま通る。残る問題は「is:inline で HTML に直書きされたスクリプト」と「CDN から読む外部スクリプト」の2種類だけに絞り込めた、というわけです。フレームワーク側が半分肩代わりしてくれていたのは、後から振り返るとありがたい設計でした。
ちなみに、最初は構造化データ(JSON-LD)のブロックも直さなきゃと身構えたのですが、あれは application/ld+json というデータで、そもそもブラウザが実行しません。CSP の script-src の対象外です。ここは慌てて対策しかけて、途中で「これは要らない」と気づいて引き返しました。
nonce を付けようとして、SSG の壁にぶつかった
インラインを許可する方法を調べると、CSP の定番は2つあります。リクエストごとにランダムな nonce を発行してスクリプトタグと突き合わせる方式と、スクリプト本文の sha256 ハッシュをあらかじめ許可リストに書いておく方式です。
私は最初 nonce でいこうとしました。より安全とされているからです。でも、実装しようとした瞬間に手が止まりました。
nonce は「リクエストごとに違う乱数」であることが安全性の前提です。ところがうちは静的サイト。astro build で吐いた HTML を S3 に置き、CloudFront がキャッシュして全訪問者にまったく同じ HTML を返します。ここに固定の nonce を焼き込んだら、それは全ユーザーで共通の値になり、乱数である意味がなくなる。かといってリクエストごとに変えるには、CloudFront Function や Lambda@Edge で配信のたびに HTML を書き換える仕組みが要る。静的配信の速さと安さを捨てることになります。
ここが、動的なサーバーを持つサイトと静的サイトで CSP の作法が変わる分岐点でした。SSG では nonce は素直に使えない。だからインラインはハッシュで許可する、というのが現実的な落としどころになります。
ハッシュは、スクリプトの中身から機械的に計算できます。
# インラインスクリプトの中身(タグの中だけ)を sha256 でハッシュ化する
# 改行や空白まで完全一致で計算されるので、1文字でもズレると通らない
printf '%s' "$(cat inline-gtag-init.js)" | openssl dgst -sha256 -binary | openssl base64
# 出力例: 47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=
# → CSP には sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU= として書く
Report-Only で「壊さずに」様子を見る
もう一つ、いきなり本番で有効化したことを反省しました。CSP には、違反をブロックせず報告だけするモードがあります。Content-Security-Policy-Report-Only というヘッダで配信すると、ポリシーに反するスクリプトも実際には止めず、コンソールと(設定すれば)指定エンドポイントに違反レポートだけを飛ばします。
段階導入の順番はこうしました。
- まず
Report-Onlyで緩めに流し、実際に何が引っかかるかを一定期間ためる - 集まった違反から許可すべきドメインとインラインのハッシュを確定する
- 表示崩れが無いことを確認してから、本番の
Content-Security-Policy(enforce)に切り替える
CloudFront のレスポンスヘッダポリシーの CustomHeadersConfig に、まず Report-Only として1行足します。
CustomHeadersConfig:
Items:
- Header: Content-Security-Policy-Report-Only
Override: true
Value: >-
default-src 'self';
script-src 'self'
'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='
https://www.googletagmanager.com
https://cdn.jsdelivr.net;
connect-src 'self' https://www.google-analytics.com;
img-src 'self' data: https://www.googletagmanager.com;
style-src 'self' 'unsafe-inline';
frame-ancestors 'none';
object-src 'none';
base-uri 'self'
- Header: Permissions-Policy
Override: true
Value: "geolocation=(), camera=(), microphone=()"
ここは正直に「改善の余地あり」と書いておきます。この方式だと、許可するインラインが増えるたびにハッシュを1つずつ手で追記することになり、ヘッダ文字列がどんどん長くなります。インラインを1文字直しただけでハッシュが変わって計測が黙って死ぬ、という事故も起こりえます。本来は、ビルド時にインラインを走査してハッシュ入りの CSP を自動生成し、CloudFront Function で付与する、くらいまで作り込みたい。ただそこはコストと手間のバランスで、今はまだ手管理のままです。style-src に 'unsafe-inline' が残っているのも、Astro が吐く動的なスタイルを全部ハッシュ化しきれておらず、妥協している箇所です。
効く範囲と、効かない範囲を分けて考える
作業しながら、CSP を「万能のセキュリティ対策」と思い込まないほうがいいと感じました。
CSP がちゃんと効くのは、XSS でインラインに注入されたスクリプトの実行を止める、frame-ancestors 'none' でクリックジャッキングを防ぐ、http: リソースの混在(mixed content)を塞ぐ、といった範囲です。ここは間違いなく価値があります。
一方で、効かない範囲もはっきりあります。たとえばうちは Alpine.js を jsdelivr の CDN から読んでいますが、CSP で https://cdn.jsdelivr.net を許可するということは、「その CDN が配るものは信じる」と宣言しているのと同じです。CDN 側が汚染されたら CSP はむしろ素通りさせてしまう。ここは CSP の担当外で、integrity 属性(SRI)で中身のハッシュを固定するか、そもそも自ドメインに置く(self-host)かで守る領域です。依存の脆弱性そのものも、以前 npm の監査を整理したときに書いたとおり、CSP とは別のレイヤーの話です。
コスト面も、運用を考えると無視できません。CloudFront のレスポンスヘッダポリシーでヘッダを足すだけなら追加費用はかかりません。リクエストごとに動的にヘッダを組み立てたいなら CloudFront Function(100万リクエストあたり数十円規模)、HTML の書き換えまで踏み込むなら Lambda@Edge、と段階的に高くなっていきます。うちの規模だと、まずは無料で済むレスポンスヘッダポリシー+ハッシュ手管理で十分だと判断しました。マネージドで足りるものをわざわざ自前実装するのは、運用の負債を増やすだけだと考えています。
振り返って
「ヘッダは揃っている」という思い込みが、一番大事な1枚を見落とさせていた、というのが今回の一番の学びでした。セキュリティヘッダはチェックリストの各項目を独立に見ないと、マネージドな枠に収まるものだけで満足してしまう。
今のところ本番は Report-Only で回していて、数週間ぶんの違反レポートを見てから enforce に切り替える予定です。並行して、Alpine の self-host+SRI 化と、ビルド時にハッシュ入り CSP を自動生成する仕組みは、次の改善テーマとして残しています。
CSP は「入れて終わり」ではなく、サイトに手を入れるたびに付き合い続けるヘッダなんだな、というのが正直な実感です。だからこそ、手管理のうちに事故らない運用の型を先に作っておきたいと考えています。