- Cloudflare
- Nuxt
- セキュリティ
- CSP
Cloudflare Pages の _headers は Worker が返す HTML に効かない — セキュリティヘッダーと CSP を enforce するまで
Nuxt を cloudflare-pages preset でデプロイすると、HTML は全部 Worker が返すので public/_headers のヘッダーが乗らない。X-Frame-Options が実質ゼロだった話と、CSP を Report-Only で実測してから enforce に上げた手順、script-src に unsafe-inline を残さざるを得なかった理由。
公開: 約 8 分namespace
Cloudflare Pages には public/_headers というファイルを置くだけでレスポンスヘッダーを付けられる便利な仕組みがあります。セキュリティヘッダーを一通り書いて、安心していました。
ところが本番で実測したら、アプリの画面には 1 つも付いていませんでした。 X-Frame-Options が無く、クリックジャッキング対策が実質ゼロ。この記事は、なぜそうなるのかと、どう直したかの記録です。
何が起きていたか
Nuxt を nitro.preset: 'cloudflare-pages' でビルドすると、dist/ に _worker.js と _routes.json が出力されます。_routes.json はだいたいこんな形です。
{
"version": 1,
"include": ["/*"],
"exclude": ["/_nuxt/*", "/favicon.ico", "/icons/*", ...]
}exclude に並んだ静的アセットだけが Pages の静的配信を通り、それ以外(= HTML 全部)は Worker が応答します。そして _headers が効くのは静的配信のときだけ。結果はこうでした。
| パス | 誰が返すか | _headers |
|---|---|---|
/favicon.ico、/_nuxt/*.js | 静的配信 | 効く(5 件付いていた) |
/、/parent/dashboard | Worker | 効かない(0 件) |
「functions/_middleware.ts でヘッダーを付ければいい」と思うかもしれませんが、これも使えません。_worker.js があると Pages は Advanced Mode になり、functions/ ディレクトリは丸ごと無視されます(Cloudflare の仕様)。
直し方:Worker 側(Nitro の routeRules)で付ける
HTML を返しているのが Worker なら、Worker にヘッダーを付けさせるしかありません。Nuxt では nitro.routeRules で書けます。
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
preset: 'cloudflare-pages',
routeRules: {
'/**': {
headers: {
'X-Frame-Options': 'DENY',
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
'Permissions-Policy': 'camera=(self), microphone=(), geolocation=(), payment=()',
'Content-Security-Policy': '...'
}
},
// 検索に載せたくないページは X-Robots-Tag も同じ場所で
'/legal/tokushoho': { headers: { 'X-Robots-Tag': 'noindex' } }
}
}
})public/_headers は消さないでください。 静的アセットはこの routeRules を通らないので、両方ないと全体を覆えません。値は 2 箇所で揃える必要があります。
X-Robots-Tag: noindex を _headers に書いても HTML には届かないので、「noindex にしたつもりで検索に載っていた」も起こり得ます。法務ページのように本名が出るページは特に注意。 CSP を Report-Only から enforce に上げる
Content-Security-Policy は、いきなり enforce にすると何かが壊れます。手順はこうしました。
Content-Security-Policy-Report-Onlyで本番に出す- E2E テスト(Playwright)で 13 画面を回り、ブラウザのコンソールに出る違反を全部集める
- 違反を潰すか、潰せない理由を確認する
Content-Security-Policyに切り替える
13 画面を回って出た違反はインライン script だけでした。connect-src、img-src、style-src、font-src、frame-src、form-action、base-uri、object-src は 1 件も出ていません。
script-src に 'unsafe-inline' を残した理由
本番の HTML には実行されるインライン script が 2 つあり、どちらも外せませんでした。
- @nuxtjs/color-mode の FOUC 防止スクリプト(落ちるとテーマがちらつく)
window.__NUXT__.config(Supabase の URL と anon key。落ちると起動しない)
hash で許可する案は採れません。config の中身は環境ごとに変わるので hash も変わり、CSP は routeRules としてビルド時に Worker へ焼き込まれるため、生成後の HTML から hash を採って差し込む経路がないからです。nonce にすると毎リクエスト HTML を書き換えることになり、SPA として静的に配る利点が消えます。
それでも enforce にする価値はあります。'unsafe-inline' が効くのは script-src だけで、残りは実際に止まるようになるからです。
connect-src… XSS が起きても攻撃者のサーバーへ送信できないobject-src 'none'/base-uri 'self'… プラグイン・<base>乗っ取りを封じるform-action 'self'… 入力の外部への横流しを封じるframe-ancestors 'none'… X-Frame-Options より広く効く
最終的な CSP はこんな形です(Supabase と Google OAuth を使うアプリの例)。
default-src 'self';
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com;
img-src 'self' data: blob: https://*.supabase.co https://lh3.googleusercontent.com;
connect-src 'self' https://*.supabase.co wss://*.supabase.co https://accounts.google.com;
frame-src https://accounts.google.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none'まとめ
- Cloudflare Pages + Worker の構成では、
_headersは静的アセットにしか効かない。HTML のヘッダーは Worker(Nitro の routeRules)で付ける - 「付けたつもり」を防ぐには、本番で
curl -Iして実測する - CSP は Report-Only → E2E で違反を実測 → enforce の順。script-src の 'unsafe-inline' が残っても、他の指示子が効くだけで守れる範囲は大きい
About the author
namespace(ネームスペース)
フリーランスの IT コンサルタント兼エンジニア。基幹システム刷新・データ基盤・FA から、Web / SaaS 開発、業務自動化、AI 開発まで一気通貫で支援しています。 相談する →
More posts