CSP
Content Security Policy ・ しーえすぴー
実行してよいスクリプトの出どころをヘッダで宣言し、注入されたXSSの発火を止めるブラウザの仕組み。
✦ 30秒で試す — 航海XSSの穴が空いたままでも、攻撃のコードは動かせない差し込まれたタグから、攻撃のコードを動かせる?予想してみる概要
CSP(Content Security Policy)は、「このページで実行してよいスクリプト、読み込んでよい画像やスタイルはこれだけ」という許可リストを、サーバが HTTP の応答ヘッダで宣言し、ブラウザに守らせる仕組みです。たとえば Content-Security-Policy: script-src 'self' と返せば、ブラウザは自分のオリジンから読み込んだスクリプトだけを実行し、よその配信元のスクリプトや、HTML の中に直接書かれたインラインスクリプトは、ページに紛れ込んでいても実行しません。
守りたい相手の筆頭は XSS です。XSS は、攻撃者の JavaScript を被害者のブラウザで「そのサイト自身のスクリプト」として動かす攻撃でした。出力時のエスケープが「注入させない」ための防御だとすると、CSP は「注入されても実行させない」ための網です。エスケープが1か所漏れてスクリプトのタグが HTML に入り込んでも、許可リストにないものはブラウザが止めます。どちらか一方ではなく、両方を重ねて使う多層防御の要になる語彙です。
なぜ生まれたか
XSS の対策の基本は出力時のエスケープですが、現実のアプリでは「すべての出力箇所で、文脈に合ったエスケープを、1か所も漏らさずに行う」ことが求められます。テンプレートの書き方ひとつ、外部のウィジェットひとつ、古いライブラリの脆弱性ひとつで穴が開きます。守る側は全箇所で勝ち続ける必要があり、攻める側は1か所見つければよい、という非対称な戦いです。2000年代を通じて XSS が脆弱性の報告の常連であり続けたことが、アプリ側の注意だけでは根絶できないことを示していました。
問題の根は、ブラウザが HTML の中の <script> を「サイトが書いたもの」と「後から差し込まれたもの」で区別できないことにあります。そこで、サイト自身がブラウザに「実行してよいものの目録」を渡し、目録にないものは実行させない、という発想が生まれました。Mozilla の Brandon Sterne らが2009年ごろに提案し、2011年の Firefox 4 で(X-Content-Security-Policy という名前で)実装されたのが始まりです。その後 W3C で標準化され、Level 2 で nonce と hash が、Level 3 で 'strict-dynamic' が加わりました。信頼の判断を、アプリの無数の出力箇所から応答ヘッダ1行の宣言に集めたのが CSP です。
詳細
ディレクティブ — リソースの種類ごとの許可リスト
ポリシーは「ディレクティブ」の並びで書きます。リソースの種類ごとに許可する出どころを指定でき、代表的なものに script-src(JavaScript)、style-src(CSS)、img-src(画像)、connect-src(fetch や WebSocket の接続先)、frame-ancestors(自分を iframe に埋め込んでよいサイト。クリックジャッキング対策)があります。個別の指定がない種類には default-src が使われるので、default-src 'self' で全体を締め、必要な種類だけ緩めるのが基本形です。
許可の書き方には、'self'(自分のオリジン)、https://cdn.example のようなホスト、'nonce-…' や 'sha256-…' のような「印」、'unsafe-inline' のような例外指定があります。オリジンは「スキーム + ホスト + ポート番号」の組で、CORS と同じ単位ですが、向きが逆です。CORS は「よそのページに自分の応答を読ませてよいか」を応答を返す側が宣言し、CSP は「自分のページに何を読み込み、何を実行するか」をページの側が宣言します。
インラインスクリプトと nonce・hash
CSP を有効にすると、既定では HTML に直接書かれたインラインスクリプト、onclick="…" のようなイベントハンドラ属性、eval がすべて禁止されます。これは意図した設計です。注入されるコードの大半はインラインの形を取るので、「どこから来たか」を判別できないインラインを一律に止めることが、CSP の防御力の核心になっています。
それでも正規のインラインスクリプトが必要な場面はあります。そのための印が nonce と hash です。nonce はサーバが応答ごとに作る使い捨ての乱数で、ヘッダに 'nonce-r4nd0m' と書き、正規の <script> タグに nonce="r4nd0m" と同じ値を付けます。攻撃者は応答が作られる前にその値を知りようがないので、差し込んだタグに正しい nonce を付けられません。値を使い回すと意味がなくなるので、必ず応答ごとに作り直します。hash は、許可したいスクリプトの本文をハッシュ化した値('sha256-…')をヘッダに書く方式で、本文が1文字でも変われば一致しません。静的なページで nonce を埋め込めないときに向いています。
逆に 'unsafe-inline' は「インラインなら何でも可」という指定で、サイトが書いたものと差し込まれたものを区別しないので、XSS に対して CSP を事実上無力にします。ヘッダに nonce か hash が1つでもあれば、それを理解するブラウザは 'unsafe-inline' を無視します。古いブラウザ向けの保険として併記する書き方はありますが、'unsafe-inline' だけに頼るのは「導入をあきらめた」のと同じです。
許可リストの抜け道と strict CSP
ホスト名を並べる許可リストには、構造的な抜け道があります。許可したホストの上にあるファイルは、どれでも「許可された配信元のスクリプト」になるからです。大手の CDN を丸ごと許可すると、その CDN に置かれた古い JSONP のエンドポイント(呼び出し元が関数名を指定できる API)や、古い版のライブラリを踏み台にして、許可リストの内側から攻撃コードを動かされます。Google の研究者が2016年に発表した調査では、実際に使われていたホスト名ベースのポリシーの9割以上が、こうした方法で迂回できる状態でした。
そこで勧められるようになったのが、ホストを並べるのをやめて nonce(または hash)と 'strict-dynamic' を軸にする「strict CSP」です。'strict-dynamic' は「nonce で信頼したスクリプトが、自分で createElement('script') して追加したスクリプトも信頼する」という指定です。サイトのローダーやタグマネージャが動的に読み込む子スクリプトを、ホストを列挙しなくても動かせます。一方、HTML の中に書かれたタグ(パーサが読んだもの)は nonce がなければ実行されないので、差し込まれたタグは止まります。'strict-dynamic' があるとき、それを理解するブラウザは 'self' やホストの指定を無視します。
下の部品では、ヘッダの許可リストをタイルで組み立てます。同じページに、サイト自身のスクリプト3つと、コメント欄に差し込まれたスクリプト2つが載っています。CDN のホストを許可すると何が通ってしまうか、nonce と 'strict-dynamic' でどう変わるかを確かめてください。
いまは CSP のヘッダがなく、コメント欄に差し込まれた2つも実行されています。まず 'self' のタイルを上のヘッダの枠へ運んでください(タップでも出し入れできます)。
Report-Only で段階的に導入する
CSP をいきなり本番に適用すると、把握していなかった正規のスクリプト(アクセス解析のタグ、広告、古い管理画面のインラインコードなど)まで止めてサイトが壊れる、というのが定番の事故です。そのために Content-Security-Policy-Report-Only ヘッダがあります。このモードのブラウザは何も止めず、「本適用ならこれを止めていた」という違反レポートを指定の宛先に送るだけです。まず Report-Only で数週間運用してレポートを集め、正規のリソースをポリシーに反映しきってから本適用に切り替えるのが定石です。
レポートの宛先は、古くは report-uri ディレクティブで書いていましたが、CSP Level 3 では Reporting API を使う report-to に移りつつあります。report-to に対応していないブラウザもまだあるので、移行期のいまは両方を書いておくのが無難です。本適用のあともレポートを集め、監視の一部として違反を見ていれば、ポリシーの綻びだけでなく、実際の攻撃の試みを知るセンサーにもなります。
守備範囲と Trusted Types
CSP は XSS の根本対策であるエスケープの代わりではなく、それが破られたときに被害を止める第二の防衛線です。スクリプトの実行を必要としない攻撃、たとえば CSRF には効きません。また、許可したスクリプト自身が、URL の断片などを innerHTML に書き込んでしまう DOM ベースの XSS は、nonce 付きの正規のコードの中で起きるので、許可リストだけでは止めにくい種類です。
これに対する仕組みが Trusted Types です。CSP のヘッダに require-trusted-types-for 'script' と書くと、ブラウザは innerHTML のような危険な書き込み先(シンク)に素の文字列を渡すことを禁止し、サニタイズを通した専用の型の値だけを受け付けます。DOM への危険な書き込みを、コードの中の数か所の「型を作る関数」に集められるので、監査の範囲が小さくなります。Chromium 系が先行して実装し、ほかのブラウザにも広がりつつあります。
試問
ある通販サイトのページに、サイト自身のスクリプトが3つ(app.js・app.js が後から読み込む CDN 上の chart.js・インラインの初期化)と、コメント欄に差し込まれた攻撃が2つ(インラインの script と、CDN 上の古い JSONP を読むタグ)載っています。応答ヘッダの script-src にタイルを置く・外すと、5つの実行・ブロックがその場で切り替わります。
いまは CSP のヘッダがなく、コメント欄に差し込まれた2つも実行されています。タイルをヘッダの枠へ運ぶ(タップでも出し入れできる)と、判定がその場で変わります。
