自社サイトで wrangler deploy を実行したところ、Cloudflare の _headers にある「1行2000文字」の上限に引っかかってデプロイが止まりました。
原因は CSP の script-src に並べていたインラインscriptの sha256 ハッシュです。ページを足すたびにハッシュが増え、1行が少しずつ伸びていました。
対処として、ハッシュを「共通行」と「ページ別ルール」の2層に分けました。最長行は 2077 文字から 1374 文字に下がり、ページを追加しても共通行は伸びなくなりました。
前提
- 静的サイトを
wrangler deployで Cloudflare に載せている - レスポンスヘッダは
_headersファイルで付けている -
_headersはビルド時に自動生成する- 生成元は
src/lib/csp.mjs - 生成された
_headersの先頭に「直接編集しても次のビルドで消える」と書いてある
- 生成元は
- CSP の
script-srcには'unsafe-inline'を使わず、インラインscriptの sha256 ハッシュを並べている(Observatory 対策) - 障害の時点で、サイト全体のユニークなインラインscriptは18個。ハッシュの部分だけで954文字あった
起きたこと
CSP に埋め込み用のホストを2つ足して wrangler deploy を実行すると、次のエラーで失敗しました。
Invalid _headers configuration: Line 6: Line exceeds limit of 2000 characters [code: 100324]
ホストを足す直前までデプロイは通っていました。
ただし、上限2000文字に対する余裕は8文字しか残っていませんでした。
ホスト2つ分の文字数で、6行目の CSP が上限を超えた形です。
原因:ハッシュ列挙は「ページを足すほど伸びる」構造
'unsafe-inline' を使わずにハッシュを並べる運用には、次の性質があります。
- インラインscriptの中身が1文字でも違えば、別のハッシュが必要になる
- ツールページや LP を1本足すたびに、そのページだけのscriptのハッシュが共通の CSP 行に加わる
- 全ページ共通の
/*ルールにまとめて載せると、1行が単調に伸び続ける
今回落ちた直接のきっかけはホストの追加です。ただ、ハッシュが増え続ける構造そのものが、いずれ上限に当たる時限爆弾でした。
対処の方針:ハッシュを2層に分ける
インラインscriptを「何ページに出るか」で2種類に分けました。
| 層 | 載せるハッシュ | 本数・文字数 | 置き場所 |
|---|---|---|---|
| 共通行 | 半数以上のページに出るscript(テンプレート由来) | 4本・212文字 | 全ページ共通のルール |
| ページ別 | 特定のページにしか出ないscript(ワンオフ) | ページごと | パス別ルールで CSP を上書き |
ワンオフのscriptを持つページは24ページありました。
この24ページには、パス別のルールで「共通のハッシュ+そのページだけのハッシュ」を並べた CSP を発行し直します。
手順
1. 生成物ではなく生成元を直す
最初は生成された _headers を直接編集してしまい、次のビルドで書き戻されて1回無駄にしました。
_headers の先頭に書いてあるとおり、修正先は生成元の src/lib/csp.mjs です。
2. インラインscriptをページ数で振り分ける
全ページのインラインscriptのハッシュを集め、ハッシュごとに出現ページ数を数えます。
- 半数以上のページに出る → 共通行へ(今回は4本)
- それ以外 → そのページのパス別ルールへ
3. _headers を2層で出力する
出力の形は次のとおりです(ハッシュ値と他のディレクティブは伏せています)。
/*
Content-Security-Policy: <共通のディレクティブ>; script-src <許可ホスト> 'sha256-<共通1>' 'sha256-<共通2>' 'sha256-<共通3>' 'sha256-<共通4>'
/<ワンオフscriptを持つページのパス>
! Content-Security-Policy
Content-Security-Policy: <共通のディレクティブ>; script-src <許可ホスト> 'sha256-<共通1>' 'sha256-<共通2>' 'sha256-<共通3>' 'sha256-<共通4>' 'sha256-<このページ固有>'
ポイントは ! Content-Security-Policy の行です。
/* から継承した CSP をパス別ルールでいったん外し、共通ハッシュとページ固有ハッシュをまとめた CSP を1本だけ付け直します。
4. postbuild に「行長の検査」を入れる
ビルド後に _headers を読み、2000文字を超える行があればビルドを止める検査を足しました。
考え方だけを最小の形にした例を以下に示します(実際のスクリプトそのものではありません)。
// postbuild の検査の考え方(最小例)
import { readFileSync } from "node:fs";
const LIMIT = 2000;
const lines = readFileSync("<出力先>/_headers", "utf8").split("\n");
lines.forEach((line, i) => {
if (line.length > LIMIT) {
console.error(`_headers ${i + 1}行目が ${line.length} 文字(上限 ${LIMIT})`);
process.exit(1);
}
});
上限を超えた時点ではなく、上限に近づいた時点で気づけるようにするのが、次の落とし穴の話です。
落とし穴
同じ CSP ヘッダーを複数行で足しても union にはならない
「ページ固有のハッシュだけを、2本目の Content-Security-Policy として追加すればいい」と考えたくなりますが、この方法は使えません。
CSP が複数ある場合は和集合(union)ではなく、すべてを満たす必要がある AND で評価されます。
- 1本目(共通)にないページ固有のscriptは、1本目に止められる
- 2本目(固有)にない共通のscriptは、2本目に止められる
このため、パスごとに ! で外してから1本にまとめ直す形が正解です。
検査が「緑」でも余裕がほとんど無い
障害の直前、行は上限の内側に収まっていたので、どの検査も緑でした。
実際の余裕は8文字です。上限つきの設定ファイルでは、今の長さではなく「上限までの余裕」を検査の対象にする必要があります。余裕が2桁の文字数しか残っていない状態を緑にしてはいけません。
生成物を直接直してしまう
手順1で書いたとおりです。自動生成ファイルを触る前に、ファイルの先頭を読みます。
結果
- 共通行は 1,320 文字になった
-
_headersの最長行は 2077 文字 → 1374 文字 - ページを足しても、増えるのはそのページのパス別ルールだけで、共通行は伸びない
- 上限を超える行が出たらビルドの段階で止まるようになった
まとめ
- Cloudflare の
_headersには1行2000文字の上限がある(code: 100324) - CSP のハッシュ列挙を全ページ共通の1行にまとめると、ページを足すたびに上限へ近づく
- 「半数以上のページに出るscript」だけを共通行に置き、ワンオフは
! Content-Security-Policyを使ったパス別ルールで上書きする - 複数の CSP は AND で評価されるため、追加分を2本目のヘッダーとして足す方法は使えない
- 上限つきの設定ファイルは、長さではなく「上限までの余裕」を検査する