p4ni.

比較

CSP の nonce と hash:エッジで配る nonce は攻撃者のスクリプトにも付く

· 読了まで約13分

目次

nonce と hash を比べた記事は、だいたい同じ結論に着地する。hash は壊れやすい——空白ひとつで合わなくなる、Prettier をかけたら合わなくなる、CI が通らなくなる。だから nonce を使え。静的サイトは自前で nonce を作れないが、Cloudflare Workers を前に置いてリクエストごとに HTML を書き換えれば、アプリに触らずに nonce が手に入る。

このブログは hash ベースの CSP を Cloudflare Workers で運用しているので、上の話は「お前の選択は間違いだ」と言われているに等しい。切り捨てる前に Worker 版を組んで、ブラウザから叩いてみた。宣伝どおりに動く。そして動くこと自体が問題だった。書き換え役はレスポンス内のインラインスクリプトすべてに署名するが、どれを自分が書いたのかを知る手段を持っていない。

静的サイトはリクエストごとの値を作れない

nonce の仕組みはこうだ。サーバーがレスポンスごとにランダムな値を選び、正規の <script> すべてにその値を書き込み、CSP ヘッダーにも同じ値を載せる。注入されたスクリプトは値を当てられないので実行されない。

この説明のどこを取ってもリクエスト時に動くコードが要る。このサイトの dist/Cloudflare Workers の static assets に置かれ、ファイルとしてそのまま配られる。値を書き込む隙間がどこにもない。ビルド時に埋め込めば、それは定数だ。定数の nonce は無いより悪い。注入された markup がいくらでもコピーできる、恒久的な許可リストの一行になる。

hash にサーバーは要らない。インラインスクリプトの中身を SHA-256 にかけ、そのダイジェストを script-src に並べる。ブラウザは見つけたスクリプトを同じように計算して照合する。

定番の回避策を実装して計測する

Cloudflare の HTMLRewriter は、レスポンスを流しながら要素を書き換える。30 行で例の構成ができる。

// worker.mjs
class Stamp {
  constructor(nonce) {
    this.nonce = nonce;
  }
  element(el) {
    el.setAttribute('nonce', this.nonce);
  }
}

export default {
  async fetch(request, env) {
    const bytes = new Uint8Array(16);
    crypto.getRandomValues(bytes);
    const nonce = btoa(String.fromCharCode(...bytes));

    const res = await env.ASSETS.fetch(request);
    const headers = new Headers(res.headers);
    headers.set('content-security-policy', `default-src 'self'; script-src 'nonce-${nonce}'`);

    return new HTMLRewriter()
      .on('script', new Stamp(nonce))
      .transform(new Response(res.body, { status: res.status, headers }));
  },
};

検証では asset store を叩かず、固定の HTML を返した。スクリプトは 3 つ。うち 1 つは、自分の承認を経ずに HTML へ入り込んだ markup の代役だ。

<script>document.title = 'legit script ran'</script>
<!-- pretend an XSS put this in the origin HTML -->
<script>window.__pwned = true</script>
<script src="/app.js"></script>

wrangler dev で立てて curl した結果がこれ。

content-security-policy: default-src 'self'; script-src 'nonce-ePcmaRi4noW+NAv9riNctA=='

<script nonce="ePcmaRi4noW+NAv9riNctA==">document.title = 'legit script ran'</script>
<!-- pretend an XSS put this in the origin HTML -->
<script nonce="ePcmaRi4noW+NAv9riNctA==">window.__pwned = true</script>
<script src="/app.js" nonce="ePcmaRi4noW+NAv9riNctA=="></script>

値はリクエストごとに変わる。連続して 3 回叩くと yK/2mSLm2LTjw2IG5VN1BA==abmttQqQAZGNDMFO05QjzA==z6k0MIWwFTVKMXCl65cQZA== が返った。仕様が求める意味では、これは本物の nonce だ。

同じページを Chrome で開くと、その本物が何を買ってくれたのかが分かる。

{
  "title": "legit script ran",
  "pwned": true
}

両方とも実行された。ポリシーに 'unsafe-inline' は無く、hash も無く、script-src には nonce しか書いていない。それでも注入したスクリプトは動いた。Worker が出口で有効な nonce を押してやったからだ。

nonce が守っているのは乱数ではなく出どころ

ランダムであることは本質ではない。nonce が機能するのは、値を押す側がどのスクリプトが正規かを知っているからだ。押しているのは自分のテンプレートエンジンで、自分が書き出したタグに印を付けている。値が推測できないことは、その前提の上に乗る二番目の条件でしかない。

script にマッチさせるだけのエッジの書き換え役は、その前提を持たない。HTML が Worker に届く時点で、自分のスクリプトと攻撃者のスクリプトは同じもの——バイト列の中の <script> 要素だ。script を選んで setAttribute を呼ぶのは、守れない約束をすることに等しい。実体は、遅延が増えた自動 'unsafe-inline' に近い。

何の役にも立たないという話ではない。読み込みの後から document.createElementappendChild で差し込まれたスクリプトには nonce が付かないので、そちらは止まる。hash ベースのポリシーが止めるのと同じ DOM 由来の XSS だ。エッジの nonce が取りこぼすのは、まさに hash が担当している領域——HTML そのものに入り込んだ信用できない内容のほうである。MDX から組み立てているブログでは、これは仮定の話ではない。MDX は生の HTML を素通しする仕様なので、現実に効いてくるのはこちらだ。

調べるときに一度は引っかかる挙動を先に書いておく。属性を読み返しても何も返ってこない。

script.getAttribute('nonce'); // ""
script.nonce;                 // 実際の値

これは nonce hiding と呼ばれる意図的な仕様だ。MDN が挙げている例は CSS の属性セレクタで、script[nonce~="whatever"] { background: url(...) } を並べれば値を 1 文字ずつ抜き出せてしまう。だからブラウザはコンテンツ属性を空にし、IDL プロパティ側に値を残す。書き換えが効いていないと早合点する前に知っておきたい。

それでも入れる場合に払うもの

DOM 差し込み対策として、あるいはスキャナを黙らせるために、それでもエッジの nonce を入れたいことはある。ただし無料ではない。

HTML のリクエストがすべて課金対象になる。 Cloudflare のドキュメントは曖昧さなく書いている。“Requests to static assets are free and unlimited”、そして “Requests to the Worker script (for example, in the case of SSR content) are billed according to Workers pricing”。HTML を書き換えるということは Worker を先に走らせるということで、無料・無制限の経路から自分で降りることになる。

同時に退避先も無くなる。 run_worker_first を設定すると、パターンに一致したリクエストは “will always invoke your Worker script. If you exceed your free tier request limits, these requests will receive a 429 (Too Many Requests) response instead of falling back to static asset serving”。静的なブログにとって、アクセスが跳ねるのは普通なら一番安上がりな出来事だ。Worker を経由させると、同じ跳ね方が 429 の山に変わる。

リクエストごとに変わる値は、キャッシュできないレスポンスと同義だ。 nonce が違うことを目的にしている以上、2 人の訪問者が同じ HTML を共有できない。CSS・JS・画像のキャッシュは残るが、ドキュメント側のレスポンス全体のキャッシュは手放すことになる。

訪問者ごとに HTML が変わらないサイトでは、実測したところ存在しなかったセキュリティ上の性質と引き換えに、この 3 つを払う勘定になる。

Astro が hash を選んだ理由と、meta に入れた代償

Astro は 5.9 で CSP 対応を入れ、hash を選んだ。リリース記事の説明は率直だ。“Using the Response header wouldn’t work for Astro, because it would leave out static websites and SPAs. For this reason, we decided to use the <meta> element to provide the CSP to the browser”。hash が選ばれたのは美しいからではない。サーバーが無い状況で生き残る側だったからだ。

<meta> に入れるという判断には、頼る前に知っておくべき副作用がある。meta 要素で配られたポリシーは、一部のディレクティブを黙って捨てる。4 つまとめて書いて試した。

<meta http-equiv="content-security-policy"
      content="default-src 'self'; frame-ancestors 'none'; sandbox; report-uri /csp-report">

このページを同じオリジンの別ページから iframe に入れてみる。frame-ancestors 'none' が効いていれば、そもそも表示されないはずだ。

{ "framedTitle": "meta csp", "framedBody": "ok", "err": null }

frame は読み込まれ、同一オリジンのスクリプトから中の document をそのまま読めた。つまり sandbox も無視されている。frame-ancestors はクリックジャッキング対策であり、report-uri は違反報告を自分の手元に届けるための口だ。これが無いと、違反は読者の devtools にだけ出て誰にも届かない。meta タグの中では、そのどちらも存在しない。両方とも本物のヘッダーを必要とする。

このブログが組み込み機能を有効にせず、ビルド時に _headers を書き出しているのはこのためだ。Astro の hash は Astro がバンドルしたスクリプトを担当してくれるが、ポリシーの残りが住める場所はヘッダーしかない。

nonce が本当に要るのはどこか

hash が成立しなくなるのは、インラインスクリプトが定数でなくなった瞬間だ。script タグがユーザーごとの CSRF トークンやセッション ID など、リクエストの状態から描画された値を持つなら、ダイジェストはレスポンスごとに変わり、ポリシーに書ける値が無くなる。これは SSR の問題で、SSR の解がある。リクエストごとに描画しているなら、nonce を押すのは自分が何を書いたか知っているコードになる。エッジで真似たものと違って、こちらは本物として機能する。

hash は壊れやすいという主張も、手で管理していることを前提にしている。astro:build:done で生成するようにすれば前提ごと消える。インラインスクリプトを整形し直しても、次のビルドが別のダイジェストを吐くだけで誰も気づかない。空白に敏感なのは事実だが、それが運用の負担になるのは人間が経路に挟まっているときだけだ。

つまり判断の分かれ目は、どちらの仕組みが強そうかではなく、自分の HTML がどこで作られるかにある。

HTML の作られ方選ぶもの
事前生成され、全員に同じものが届くビルド時に生成した hash
リクエストごとに描画され、可変値がインラインに載るレンダラーが押す nonce
事前生成だが、スキャナが 'nonce-' を見たがる性質ではなく報告書を買っていると理解する

あとから踏んだもう 1 つの場面があり、これはこの表にきれいに収まらない。CDN 自身が自分のインラインスクリプトを注入してくる場合だ。Cloudflare の Bot Fight Mode がまさにそれをやっていて、埋め込まれる値にリクエストごとの ray ID が入るので、静的な hash では原理的に許可できない。そして nonce があれば、Cloudflare はポリシーを読んで自分のスクリプトにその nonce を押してくれる。事前生成のサイトで nonce を選ぶ本物の論拠ではある。ただし、その nonce を作るために立てる Worker は、上で書いたとおり、本来そこに無いはずのスクリプトにも同じ署名を押す書き換え器でもある。

まとめ

  • nonce が機能するのは、押す側がどのスクリプトを正規と見なすか知っているからだ。script にマッチさせるだけのエッジの書き換えはそれを知らないので、注入されたインラインスクリプトにも自分のスクリプトと同じ署名を付ける。実測でも、nonce だけのポリシー下で注入したスクリプトが動いた
  • エッジの nonce でも、読み込み後に DOM へ差し込まれたスクリプトは止まる。止まらないのは HTML そのものに入り込んだ信用できない内容で、hash が担当しているのはそちらだ
  • static assets の前に Worker を置くと、無料・無制限のリクエストが課金対象になり、超過時の退避先が 429 に変わり、ドキュメントがキャッシュできなくなる
  • Astro が hash を選んだのは Response ヘッダーでは “would leave out static websites and SPAs” だから。そしてポリシーは <meta> で配られ、そこでは frame-ancestorssandboxreport-uri が黙って落ちる。この 3 つが要るならヘッダーを書く
  • hash の壊れやすさは手作業の症状であって、hash の性質ではない。ダイジェストをビルドで生成すれば消える