p4ni.

チュートリアル

静的サイトに nonce は使えない: Astro + Cloudflare Workers の CSP

· 読了まで約27分

目次

このサイトは、セキュリティヘッダーを1つも付けないまま公開されていました。弱い Content Security Policy があった、という話ではありません。本当に1つもありませんでした。curl -I を叩いても content-type と Cloudflare のキャッシュ情報が返ってくるだけ。無いままでもサイトは何ひとつ壊れないので、見に行くまで気づきません。

静的サイトに CSP を入れようとすると、たいていの解説はここから先で役に立たなくなります。リクエストごとに nonce を生成できるサーバーがある前提で書かれているからです。プリレンダリング済みの HTML を Cloudflare Workers の静的アセットに置いている場合、その前提は成り立ちません。この記事では実際に本番へ入れたポリシーと、それを生成するスクリプト、そして最初の直感が外れていた2か所を書きます。必要な hash の数と、ヘッダーが本当に効いているかの確かめ方です。

nonce が選択肢に入らない理由

'nonce-...' は次のように動きます。サーバーがレスポンスごとにランダムな値を選び、正規の <script> タグすべてにその値を付け、同じ値を CSP ヘッダーに載せます。注入されたスクリプトは値を当てられないので実行されません。

どの動作も、リクエストの経路にサーバーがいて初めて成り立ちます。私の dist/ は Cloudflare のエッジにアップロードされ、ファイルとしてそのまま配信されます。リクエストごとに走るコードは存在しません。それが静的アセットの本質であり、無料かつ無制限で捌ける理由でもあります。ビルド時に埋め込んだ nonce は単なる定数で、定数の nonce は nonce が無いより悪い結果になります。注入されたスクリプトも同じ値をコピーできるので、恒久的な許可リストにしかならないからです。

残るのは hash です。インラインスクリプトの中身の SHA-256 を計算して script-src に並べ、ブラウザ側でも同じ計算をして照合させる。サーバーは要りません。

Worker を前に置いて HTMLRewriter でリクエストごとに nonce を注入すればいい、という反論はよく見ます。あとで実際に組んで確かめたところ、正規のスクリプトと一緒に注入されたスクリプトにも署名が付きました。その道を選ぶ前に読んでおくと早いはずです。

ただし hash は完全一致です。コメントを1文字書き換えただけでも値が変わり、ブラウザはそのスクリプトをブロックします。手で並べた hash のリストは負債にしかならないので、後半は生成する側に倒します。

インラインの実態を数える

ポリシーを書く前に数を数えました。ビルド出力を歩いてインライン <script> の hash を全部取ります。

// scan.mjs: pnpm build の後に実行する
// 下の正規表現は Astro の出力を相手にする分には足りる。属性値に ">" が
// 入る HTML では崩れるので、任意の HTML に向けて使わないこと
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';

const files = [];
(function walk(dir) {
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
    const full = path.join(dir, entry.name);
    if (entry.isDirectory()) walk(full);
    else if (entry.name.endsWith('.html')) files.push(full);
  }
})('dist');

const blocks = new Map();
for (const file of files) {
  const html = fs.readFileSync(file, 'utf8');
  for (const [, attrs, body] of html.matchAll(/<script([^>]*)>([\s\S]*?)<\/script>/gi)) {
    if (/\ssrc\s*=/i.test(attrs) || !body.trim()) continue;
    const hash = crypto.createHash('sha256').update(body).digest('base64');
    const type = attrs.match(/\stype\s*=\s*["']([^"']+)["']/i)?.[1] ?? 'classic';
    blocks.set(hash, { type, pages: (blocks.get(hash)?.pages ?? 0) + 1 });
  }
}

console.log(`${files.length} pages, ${blocks.size} unique inline blocks`);
for (const type of new Set([...blocks.values()].map((b) => b.type))) {
  const group = [...blocks.values()].filter((b) => b.type === type);
  const total = group.reduce((n, b) => n + b.pages, 0);
  console.log(`  ${type}: ${group.length} unique across ${total} occurrences`);
}

このブログでの結果です(執筆時点の値)。

35 pages, 37 unique inline blocks
  classic: 2 unique across 70 occurrences
  module: 1 unique across 35 occurrences
  application/ld+json: 34 unique across 34 occurrences

35ページに対して37個というのは、そのままでは筋の悪い数字です。記事を1本増やすたびに hash が1つ増え、ヘッダーが際限なく伸びていくことを意味します。誰も保守しなくなるポリシーの形です。

type の列に逃げ道が出ています。37個が実際に何なのかを並べます。

インラインの中身typeユニークな hash出現箇所
GA4 の gtag 設定なし1全35ページ
描画前のテーマ適用なし1全35ページ
テーマ切替のハンドラmodule1全35ページ
構造化データapplication/ld+json3434ページ(404 以外の全部)

コードなのは3つだけで、残る34個はページごとに吐いている JSON-LD でした。そして構造化データはブラウザの扱いがコードと違い、その差がポリシー全体を決めます。

多くの CSP 解説が取り違えるところ

HTML の prepare the script element というアルゴリズムは、<script>typeclassicmoduleimportmapspeculationrules のどれかに解決し、どれにもならなければその場で処理を打ち切ります。この打ち切られたものがデータブロックです。ブラウザは実行せず、後から別のコードが DOM 経由で読み出します。application/ld+json と素の application/json がここに入ります。

打ち切りが起きるのは、アルゴリズムが CSP のチェックに到達する前です。だからデータブロックは script-src の視界に入りません。hash も要らず、違反も報告されません。

問題は線の引かれる位置で、これは「JavaScript の MIME タイプかどうか」とは一致しません。module は MIME タイプではありませんが実行されます。importmapspeculationrules も同じで、だからこそインラインの speculation rules を 'unsafe-inline' 抜きで通すための 'inline-speculation-rules' という専用のキーワードが用意されています。script-src の対象だから専用キーワードが要る、という順序です。

<script> 要素を経由しない出し方もあって、そちらは script-src の話になりません。このサイトのレスポンスには Cloudflare の Speed Brain が付ける speculation-rules: "/cdn-cgi/speculation" というヘッダーが出ています。ルールセットの実体は別 URL の JSON で、要素ではないので hash も違反もありません。prefetch する先のほうは default-src の側で見られます。同じ機能でも、インラインで書けば script-src、ヘッダーで渡せば取得先のディレクティブ、と担当が変わります。

仕様の読み方だけを根拠に本番のポリシーを決めたくはないので、他をすべてブロックしている実際のポリシー下で確かめました。本番と同じ CSP が効いているページを開き、DevTools のコンソールから流します。コンソールでの評価そのものは CSP の対象外ですが、そこから document に挿した <script> は対象になります。見ているのは後者です。

const violations = [];
document.addEventListener('securitypolicyviolation', (e) =>
  violations.push(`${e.violatedDirective} <- ${e.blockedURI}`)
);

const inject = (props) =>
  document.head.appendChild(Object.assign(document.createElement('script'), props));

inject({ textContent: 'window.__pwned = true' });               // 1. hash の無いインライン
inject({ src: 'https://evil.example.com/x.js' });               // 2. 許可外のオリジン
inject({ type: 'application/ld+json', textContent: '{}' });     // 3. データブロック
inject({ type: 'importmap', textContent: '{"imports":{}}' });   // 4. type が解決される
inject({ type: 'speculationrules', textContent: '{}' });        // 5. 同上

await new Promise((r) => setTimeout(r, 400));
({ pwned: window.__pwned === true, violations });

返ってきた結果です。

{
  "pwned": false,
  "violations": [
    "script-src-elem <- inline",
    "script-src-elem <- https://evil.example.com/x.js",
    "script-src-elem <- inline",
    "script-src-elem <- inline"
  ]
}

5つ挿して、違反は4件でした。注入したインラインスクリプトは実行されず、許可外のオリジンも拒否されています。つまりポリシーは確かに効いていて、そのうえで音を立てなかったのは ld+json だけでした。import map と speculation rules は、ただのインラインコードと同じようにブロックされています。この2つをインラインで出す日が来たら、hash が要ります。

自分の違反レポートを読む前に1つ。レポートに出てくる script-src-elem は、ポリシーのどこにも書いていないディレクティブです。Chrome が返すのは実効ディレクティブの名前で、script-src-elem は未指定なら script-src にフォールバックします。同じ違反を Firefox は script-src と報告します。書いた覚えのないディレクティブを探しに行かないように。

必要な hash は37個ではなく3個で、記事を書き足しても増えません。構造化データまで hash を取ると、ヘッダーは587文字から2,423文字になります。4倍の長さを毎ビルド作り直して、それで守れるものは1つも増えません。

ビルド時にヘッダーを生成する

hash はインラインを書き換えた瞬間に古くなるので、手で書くべきではありません。Cloudflare Workers はアセットディレクトリに置いた _headers というプレーンテキストを読みます。だから hash の書き出しは、ビルド後に走る Astro の integration に任せられます。

// src/integrations/security-headers.mjs
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';

/**
 * ブラウザがそもそも実行しない script の type。
 * importmap と speculationrules がここに無いのは、type が解決される側で、
 * インラインなら hash が要るため
 */
const DATA_BLOCK_TYPES = new Set(['application/ld+json', 'application/json']);

const sha256 = (body) =>
  `'sha256-${crypto.createHash('sha256').update(body, 'utf8').digest('base64')}'`;

function htmlFiles(dir) {
  return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
    const full = path.join(dir, entry.name);
    if (entry.isDirectory()) return htmlFiles(full);
    return entry.name.endsWith('.html') ? [full] : [];
  });
}

function inlineScriptHashes(root) {
  const hashes = new Set();
  for (const file of htmlFiles(root)) {
    const html = fs.readFileSync(file, 'utf8');
    for (const [, attrs, body] of html.matchAll(/<script([^>]*)>([\s\S]*?)<\/script>/gi)) {
      if (/\ssrc\s*=/i.test(attrs)) continue; // 外部スクリプトはオリジンの許可リスト側で見る
      const type = attrs.match(/\stype\s*=\s*["']([^"']+)["']/i)?.[1].toLowerCase() ?? '';
      if (DATA_BLOCK_TYPES.has(type) || !body.trim()) continue;
      hashes.add(sha256(body));
    }
  }
  return [...hashes].sort();
}

export default function securityHeaders() {
  return {
    name: 'security-headers',
    hooks: {
      'astro:build:done': ({ dir, logger }) => {
        const root = fileURLToPath(dir); // new URL(dir).pathname は Windows で壊れる
        const hashes = inlineScriptHashes(root);
        const csp = [
          `default-src 'self'`,
          `script-src 'self' ${hashes.join(' ')} https://www.googletagmanager.com`,
          `connect-src 'self' https://*.google-analytics.com https://*.analytics.google.com https://*.googletagmanager.com`,
          `img-src 'self' data: https://*.google-analytics.com https://*.googletagmanager.com`,
          `style-src 'self' 'unsafe-inline'`,
          `font-src 'self'`,
          `object-src 'none'`,
          `base-uri 'self'`,
          `form-action 'self'`,
          `frame-ancestors 'none'`,
          `upgrade-insecure-requests`,
        ].join('; ');

        fs.writeFileSync(
          path.join(root, '_headers'),
          [
            '/*',
            `  Content-Security-Policy: ${csp}`,
            '  Strict-Transport-Security: max-age=31536000; includeSubDomains; preload',
            '  X-Content-Type-Options: nosniff',
            '  Referrer-Policy: strict-origin-when-cross-origin',
            '  Cross-Origin-Opener-Policy: same-origin',
            '  Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()',
            '',
          ].join('\n')
        );
        logger.info(`_headers written with ${hashes.length} inline script hash(es)`);
      },
    },
  };
}

登録は最後に置きます。他の integration が吐き終えた HTML を読むためです。

// astro.config.mjs
import securityHeaders from './src/integrations/security-headers.mjs';

export default defineConfig({
  integrations: [mdx(), sitemap({ /* ... */ }), securityHeaders()],
});

これでビルドが結果を教えてくれるようになります。

[security-headers] _headers written with 3 inline script hash(es)

この数が変わったら、インラインスクリプトが増えたか消えたかのどちらかです。変わってほしくないものが変わった瞬間に、それが目に入ります。

これは同じ日のうちに元が取れました。この記事がまだ下書きのあいだに、CSP とは関係のない変更でテーマ切替のハンドラが書き換わり、3つのうち1つの hash が変わったのです。私は CSP 側を何も触っていません。次のビルドが違うヘッダーを吐いて、切替はそのまま動き続けました。手で管理していたら、ここで黙って壊れていたはずです。しかもテーマ切替は、この壊れ方をされると一番困るスクリプトです。読者がクリックしたあとにしかおかしくならないので、誰かに教えてもらうか、最後まで気づかないかのどちらかになります。

有効にする前に

まず Content-Security-Policy-Report-Only として出します。値も置き場所も同じで、ヘッダー名が1語長くなるだけです。

`  Content-Security-Policy-Report-Only: ${csp}`,

ブラウザはポリシーを評価し、ブロックしたはずのものを報告して、実際には何もブロックしません。1日ほど流して何ページかコンソールを見てから、接尾辞を外します。これは安いほうのやり方で、丁寧にやるなら report-to の宛先を用意し、違反を読者の devtools 任せにせず自分の手元へ届かせます。私は用意していないので、公開してからの静けさは「問題が無い」ではなく「確かめていない」です。穴として書いておきます。

どちらのモードでも hash が届かないものが1つあります。script-src の hash が効くのは <script> 要素で、インラインのイベントハンドラ属性や javascript: URL には効きません。テンプレートに onclick="…" が1つ残っていると、hash をいくつ足してもブロックされたままです。通すには 'unsafe-hashes' とハンドラごとの hash が要るので、素直にスクリプト側へ移すほうが安く済みます。強制に切り替える前に on*= を grep しておきます。踏んでから気づくと厄介です。

締めきれなかった style-src

style-src には 'unsafe-inline' が残っています。これは過去に決めた2つのことが効いた結果です。

1つは build.inlineStylesheets: 'always' で、スタイルシートを各ページの中に埋め込みます。レンダリングを止める往復が1つ消え、スコアを83から99へ動かした一連の変更の一部になりました。もう1つは記事一覧で、各項目の登場アニメーションを、インデックスから計算した style="animation-delay" でずらしています。style 属性は、全部をクラスに移さないかぎり 'unsafe-inline' を要求します。

インラインの <style> ブロックは hash 化できますが、属性のほうはできません。style-src が主に防ぐのは CSS 経由のデータ流出です。それを締めるために、どちらかの最適化を捨てる取引は、ブログでは割に合いません。隙のないポリシーを装うより、穴の場所を書いておくほうが役に立つはずです。script-src は厳格、style-src は緩い、というのが多くの静的サイトの正直な姿だと思います。

デプロイで引っかかった2点

クエリ文字列は cache buster になりません。 デプロイして curl -I を叩いたら、セキュリティヘッダーが1つも返ってきませんでした。レスポンスには cf-cache-status: HIT が出ています。そこで素直に ?x=$RANDOM を足したら、ヘッダーが出ました。エッジキャッシュのせいだった、で片付けるところでした。

この説明はもう一度見ると成立しません。一度もリクエストされたことのない URL を叩いてみます。

curl -sI "https://astro.p4ni.com/blog/?zzz=$RANDOM" | grep -i cf-cache-status
# cf-cache-status: HIT

一度も叩かれていない URL が初回から HIT になる理由は、私には確定できていません。クエリ文字列がキャッシュキーから外れているのか、静的アセットがアセットストア側から返っていて別の計上になるのか。どちらにしても結論は同じで、?x=$RANDOM は何も外していませんでした。しかも今は素の URL でも、HIT のまま現在のポリシーが返ってきます。「キャッシュされたレスポンスは、キャッシュされた時点のヘッダーを保持する」という説明のほうも成り立ちません。

クエリ文字列が効いたわけではないでしょう。2回の curl のあいだに、デプロイの伝播が終わっただけだと思います。デプロイ直後にヘッダーが出ないときに実際に効く手は、待つことと、ダッシュボードからキャッシュをパージすることの2つです。待つあいだ見るのは cf-cache-status です。正しく動いているファイルのデバッグに1時間溶かすところで、そのうえ間違った理由を書くところでした。

_headers はアセットとして配信されません。 Cloudflare はこのファイルを解釈するだけで配信しないので、ポリシーの元ファイルが取得可能な状態で置かれることはありません。公開しているファイルと同じディレクトリに置く以上、確認しておく価値はあります。

curl -s -o /dev/null -w "%{http_code}\n" https://astro.p4ni.com/_headers
# 404

実際に入れたポリシー

/*
  Content-Security-Policy: default-src 'self'; script-src 'self' 'sha256-Tr6Y…' 'sha256-a1fF…' 'sha256-fMw+…' https://www.googletagmanager.com; connect-src 'self' https://*.google-analytics.com https://*.analytics.google.com https://*.googletagmanager.com; img-src 'self' data: https://*.google-analytics.com https://*.googletagmanager.com; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests
  Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin
  Cross-Origin-Opener-Policy: same-origin
  Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()

hash はソートして出しているので、スクリプトが HTML の中で前後しても、中身が同じかぎりヘッダーはビルドをまたいで1バイトも変わりません。

サードパーティはアナリティクスだけですが、これだけで3つのディレクティブを使います。gtag.js を読むための script-src、計測ビーコンを送る connect-src、ピクセルにフォールバックしたときの img-src。最初の1つだけを許可すると計測が静かに失敗します。画面上は何も壊れて見えないので、気づきにくい失敗の仕方をします。

サードパーティが1つ増えるたびに、この計算をやり直すことになります。記事末尾に広告を入れる予定があるので、そのときは script-srchttps://pagead2.googlesyndication.com、クリエイティブのための img-src、それに広告の iframe を通す frame-src が要ります。いま frame-src を書いていないのは、default-src 'self' が代わりに全部落としているからです。この default-src の1行があるおかげで、こういう追加漏れは「エラーは出ないが何も表示されない」という形で必ず表に出ます。

script-src'self' は、実はまだ外せます。このサイトの dist/ にある外部 script は gtag.js の1本だけで、自前の JS はすべてインラインです(_astro/ に入っているのはフォントだけ)。つまり 'self' が許しているのは、いま1つも存在しない同一オリジンのスクリプトファイルです。ここを外すと、同一オリジンに JSON を返すエンドポイントを <script src> で読ませる古典的な迂回路も一緒に閉じます。外部 script が増える見込みなら 'strict-dynamic' に寄せる選択もありますが、あれは hash で通したスクリプトが動的に足した script を信頼させる仕組みで、インラインが3個で足りている構成では足す理由がありません。

残りは CSP ではないので、説明は1行ずつで足ります。nosniff は MIME スニッフィングを止め、Referrer-Policy はクロスオリジンのリファラを削り、Cross-Origin-Opener-Policywindow.opener の紐を切り、Permissions-Policy はブログに用の無いデバイス API を落とします。どれもこのサイトで何かを壊したことはありません。

Strict-Transport-Security には補足が要ります。preload は意思表示であって、登録そのものではありません。hstspreload.org への申請は別途手作業です。そしてあのフォームが受け付けるのはベースドメインだけで、astro.p4ni.com 単体では申請できません。このサブドメインを preload させたいなら、p4ni.com 側で includeSubDomains 付きのヘッダーを出し、他のサブドメインもまとめて巻き込む必要があります。リストからの削除がユーザーに届くまでには数か月かかります。というわけで今このトークンは効いていません。仕上がったように見せておくより、書いておくほうを取ります。

まとめ

静的アセットにはリクエスト時のフックが無いので 'nonce-...' は使えません。hash が唯一の現実的な選択肢で、ビルド時に固定した nonce は何も無いより悪くなります。

application/ld+jsonapplication/json はデータブロックで、実行されないため script-src の対象外です。このサイトではそれが、3個で固定されるポリシーと、記事を書くたびに1つ伸びるポリシーの差になりました。moduleimportmapspeculationrules はデータブロックではないので hash が要ります(speculation rules だけは 'inline-speculation-rules' という専用キーワードでも通ります)。

hash は手で書かず astro:build:done で集めます。ビルドログに出る個数は、インラインスクリプトが増えたり消えたりしたことを知らせるカナリアとしてそのまま使えます。

まず -Report-Only で流し、そのうえで securitypolicyviolation のリスナーと、ブロックされるはずのスクリプトで検証します。違反が1件も出ないポリシーは、厳格なのか、そもそも適用されていないのか、それだけでは区別がつきません。

Cloudflare のキャッシュはクエリ文字列では外れません。cf-cache-status を見て、確実にしたいならパージします。