• 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/dashboardWorker効かない(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 にすると何かが壊れます。手順はこうしました。

  1. Content-Security-Policy-Report-Only で本番に出す
  2. E2E テスト(Playwright)で 13 画面を回り、ブラウザのコンソールに出る違反を全部集める
  3. 違反を潰すか、潰せない理由を確認する
  4. Content-Security-Policy に切り替える

13 画面を回って出た違反はインライン script だけでした。connect-srcimg-srcstyle-srcfont-srcframe-srcform-actionbase-uriobject-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' が残っても、他の指示子が効くだけで守れる範囲は大きい

この話は Finoko の運用で実際に踏んだものです。構成全体は 別の記事 にまとめています。

About the author

namespace

namespace(ネームスペース)

フリーランスの IT コンサルタント兼エンジニア。基幹システム刷新・データ基盤・FA から、Web / SaaS 開発、業務自動化、AI 開発まで一気通貫で支援しています。 相談する →

More posts