p4ni.

チュートリアル

Cloudflare Workers の _headers が効かない — ルールは上書きされず積み重なる

· 読了まで約11分

目次

このサイトは Content Security Policy を _headers ファイルで配信している。Worker のコードでも Transform Rules でもなく、デプロイ時に Cloudflare が読む1枚のテキストファイルだ。置いてから一度も事故が無かったので、ルールがどう解決されるのかを詳しく見たことがなかった。

きっかけは、他の人がどこで詰まっているかを調べたことだった。Workers の静的アセットで _headers が効かないという Cloudflare Community のスレッドと、あるルートで CSP ヘッダーが2つ返るという workers-sdk の issue 11351。どちらも同じ形の失敗を報告している。ファイルは正しくパースされ、デプロイも成功し、それでもヘッダーは書いたとおりにならない。エラーは何も出ない。

そこで実測した。以下はすべて、ルールをわざと衝突させた状態で、ローカルの wrangler dev(4.119.0)と本番に curl を打った結果だ。結論を先に言うと、マッチしたルールは上書きし合わずに積み重なる。上書きするための構文は別にあり、それを知らないと踏む罠が1つある。

ファイルはビルド出力の直下に置く

置き場所は _redirects ファイルと同じで、ビルド出力のルートになる。Astro なら public/_headers に置けば、中身がそのまま dist/ にコピーされる。

構文はパスのパターンを1行書き、その下にインデントして 名前: 値 を並べる。

/*
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin

/_astro/*
  Cache-Control: public, max-age=31536000, immutable

ファイル自体は配信されない。/_headers にリクエストすると wrangler dev でも本番でも 404 が返る。ポリシーを書いたファイルが自分の中身を晒すのは良くない既定なので、確認しておいた。

ただしこのサイトの _headerspublic/ に無い。CSP がハッシュベースなので、インラインスクリプトが変わるたびにヘッダーも変わる。手で管理するファイルは最初の編集でずれる。代わりに Astro のインテグレーションが astro:build:done フックでビルド後の HTML を走査し、dist/_headers を書き出している。nonce ではなくハッシュを使う理由は CSP の記事にまとめた。この記事の話に関しては、生成でも手書きでも違いはない。Cloudflare が見るのは同じファイルだ。

マッチしたルールは全部適用される — 上書きではない

CSP が二重になるバグはここから生まれる。そして CSS の詳細度や _redirects の先勝ちに慣れていると、まず予想しない挙動でもある。

/blog/ に両方マッチするルールを2つ、値を変えて置いた。

/*
  X-Test: from-star
  Cache-Control: public, max-age=60

/blog/*
  X-Test: from-blog
  Cache-Control: public, max-age=120

具体的なほうのルールは勝たない。先に書いたほうも勝たない。両方が適用される。

x-test: from-star
x-test: from-blog
Cache-Control: public, max-age=60, public, max-age=120

レスポンスに X-Test が2つ並ぶ。そして Cache-Control のように値がカンマ区切りのリストになるヘッダーでは、2つの値が1本に連結されて意味を成さない文字列になる。

ここで X-TestContent-Security-Policy に置き換えると、報告されていたバグの説明がつく。ブラウザは CSP ヘッダーを2つ受け取ると両方を適用し、実効ポリシーはその積集合になる。あるリソースが読み込まれるには、存在するすべてのポリシーが許可していなければならない。

つまり2つ目の CSP がどれだけ緩くても制限は緩まないし、2つ目にインラインスクリプトのハッシュが載っていなければ、1つ目がどれだけ正しくてもそのスクリプトはブロックされる。コンソールのメッセージはポリシーを指すので、ポリシーを読みに行く。そして読んだポリシーは正しい。問題はポリシーが2つあることのほうにあるからだ。

2つのルールの順序を入れ替えても、値の並ぶ順が変わるだけで結果は同じだった。ここに利用できる優先順位は無い。上書きするには明示的な指示が要る。

同じパターンを2回書くと、前のルールが黙って消える

こちらは逆方向に静かに壊れるぶん、性質が悪い。

/blog/*
  X-A: first-block

/blog/*
  X-B: second-block

返ってきたのは x-b: second-block だけで、X-A は消えていた。wrangler の起動ログは ✨ Parsed 2 valid header rules. と出る。2つとも妥当で、2つともパースされ、そのうえで片方が捨てられている。ルールはパターンをキーに持たれているので、同じパターンの2つ目のブロックは1つ目にマージされず、丸ごと置き換える。

生成したファイルでは起こしやすいし、手書きでも1画面を超えたあたりから起こる。実は最初にルールの積み重なりを測ったとき、テストファイルに /blog/* の重複ブロックを残していて、観測したかったルールが静かに食われていた。おかげで「先に書いたルールが勝つ」という誤った結論をいったん出している。この上に別の問題を重ねて調べる前に、知っておく価値はある。

直し方は機械的で、1つのパターンにつき1ブロックにまとめるだけだ。

/blog/*
  X-A: first-block
  X-B: second-block

上書きは ! で明示する

ヘッダー名の前に ! を付けると、そのヘッダーが削除される。Cloudflare の既定ヘッダーを剥がす方法として文書化されている構文だ。

/*
  ! Cache-Control
  X-Keep: yes

レスポンスは x-keep: yes だけを持ち、Cache-Control は消える。! の後ろの空白は必要になる。

分かりにくいのは、この !同じファイル内の別のルールが設定した値にも効くことと、その次の行で新しい値を設定できることだ。この組み合わせが、積み重なる挙動のせいで手に入らなかった上書きにあたる。

/*
  X-Test: from-star

/blog/*
  ! X-Test
  X-Test: from-blog
x-test: from-blog

ヘッダーは1つで、値は狭いほうのルールのものになる。広いルールでサイト全体のポリシーを敷き、特定のパスだけ別のものにしたいときは、この形を使う。消してから設定する。

なお、これは昔からできたわけではない。消してすぐ設定し直すのは feature request として出されていて、クローズしたのは 2026年2月だった。それ以前に書かれた解説はルール構成のほうを組み替えろと言っているが、いまは必要ない。

注意すべきなのは冒頭の issue 11351 のほうだ。報告者はルートパス / に限って削除が無視され、CSP ヘッダーが2つとも残ると書いている。Cloudflare は 2025年11月にバグと認めたが、まだ open のままになっている。手元の wrangler 4.119.0 では再現しなかった。/ も他のパスと同じように上書き後の値を返している。ただしローカルの wrangler dev と本番のエッジは別実装なので、/ の削除に頼るなら dev サーバーを信用せずデプロイ先に curl -I を打ったほうがいい。

Cloudflare の既定ヘッダーが相手なら ! は要らず、_headers の値がそのまま勝つ。静的アセットのレスポンスは既定で Cache-Control: public, max-age=0, must-revalidate を持つが、このサイトの /_astro/* はこれを1年に置き換えている。

$ curl -sSI https://astro.p4ni.com/_astro/page.CQWjsXKf.js
cache-control: public, max-age=31536000, immutable

既定値とカンマで連結されることもなく、値は1つだ。/_astro/ 以下はファイル名にコンテンツハッシュが入っていて、同じ URL が古いバイト列を返すことがありえない。HTML のほうはプラットフォームの既定のままにしてある。毎日デプロイするサイトに欲しいのはそちらになる。

_headers で扱えないもの

Worker のレスポンスには効かない。ルールが適用されるのは静的アセットのレスポンスだけだ。アセットの手前に Worker スクリプトを置いていて、そのコードがレスポンスを生成しているなら、ルールは適用されない。SSR でも API ルートでも、自前のコードが返すものはすべて対象外になる。公式ドキュメントは注意書きとして載せているが、ファイルが何もしていないように見える原因としてはこれが一番多い。リクエストを処理しているのがアセットの仕組みではなくコードのほうだからだ。run_worker_first で動かしているなら、トラフィックの大半がこれに当たる。

1行 2,000文字、ルール 100個の上限がある。CSP を配信しているなら気にすべきなのは行の上限のほうだ。このサイトの CSP は現在 806文字で、インラインスクリプトのハッシュを5個含んでいる。SHA-256 のハッシュ1つがおよそ52文字なので、上限まではあと20個ぶんくらいの余裕がある。当面は問題ないが無限ではないし、コンポーネントごとにスクリプトをインライン化するサイトなら到達しうる。2,000文字で切られると、ポリシーはディレクティブの途中で千切れる。

wrangler dev で測れる

wrangler dev はこのファイルを読んでルールを適用するので、確認のためにデプロイする必要はない。

npx wrangler dev --port 8788
curl -sSI http://localhost:8788/blog/

起動ログの ✨ Parsed 2 valid header rules. でパースを確認でき、ファイルを編集するとローカルサーバーがホットリロードする。この記事の結果はすべて本番でも同じように再現したので、この確認ループは信用してよい。

ただし2つ落とし穴がある。1つは、そのログの件数が正しさを何も保証しないこと。パターンを重複させたテストでは、片方を捨てながら「2つの妥当なルール」と報告していた。もう1つは、フレームワークの dev サーバーはまったく別物だということ。astro dev は Vite が配信していて _headers を読まない。このサイトに至っては、ファイルがビルド時に書き出されるので astro dev の最中には存在すらしない。ヘッダーの確認は wrangler dev かデプロイ済みのサイトに対して行い、フレームワークの dev サーバーでは行わないこと。

効かないときに見る順番

_headers の挙動がおかしいときは、上から順に潰していくと早い。

  1. レスポンスを生成しているのは Worker か。そうなら _headers は何も適用されない。コード側でヘッダーを設定する
  2. 同じヘッダーを設定するルールが複数マッチしていないか。積み重なる。CSP が2つ返るとブラウザは両方の積集合を適用する。狭いほうのルールに ! ヘッダー名 を足してから設定する
  3. 同じパターンが2回出ていないか。後のブロックが前のブロックを黙って置き換える
  4. フレームワークの dev サーバーで確認していないか。ファイルを読んでいない

_headers はカスケードではない、と考えておくと事故が減る。これはマッチャの集合で、マッチしたものがそれぞれ自分のヘッダーをレスポンスに足していく。詳細度はそれ自体では何も買えない。上書きは専用の構文を持つ明示的な操作で、狭いルールに ! を書きたくなった時点で、このファイルの仕組みは理解できている。