0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Cloudflare Workersで動的CSP nonceを注入してsecurityheaders.com A+を取る【run_worker_first + Static Assets】

0
Last updated at Posted at 2026-04-20

Cloudflare Workers + Static Assets 構成のサイトで、毎リクエストにランダムな CSP nonce を発行し、unsafe-inline を撤廃して securityheaders.com を A+ にする手順をまとめます。

ポイントは以下の 3 点です。

  • run_worker_first = true で全リクエストを Worker 経由にする
  • HTML に __CSP_NONCE__ プレースホルダーを埋めておく
  • Worker がレスポンス HTML を書き換えて nonce を埋め、同じ nonce を CSP ヘッダーに乗せる

この記事で解決すること

課題 解決
CSP で 'unsafe-inline' を残したくない nonce を使って inline script/style を明示許可
Cloudflare Pages のようなビルド時 nonce が使えない Workers でレスポンスを動的書き換え
Static Assets だけだと Worker が呼ばれない run_worker_first = true
securityheaders.com で A 止まり COEP / CORP を含めて A+ へ

前提

  • Cloudflare Workers (TypeScript)
  • public/ に静的 HTML を置く Static Assets 構成
  • wrangler v3 以降

wrangler.toml:

name = "your-worker"
main = "worker/index.ts"
compatibility_date = "2025-04-01"

[assets]
directory = "./public"
binding = "ASSETS"
run_worker_first = true  # これが肝

run_worker_first = true が無いと、/index.html のような静的アセットが Worker を経由せずに配信されるため、ヘッダー書き換えが効きません。

手順 1: HTML にプレースホルダーを埋める

public/index.html の inline <script> / <style>nonce="__CSP_NONCE__" を仕込みます。

<!DOCTYPE html>
<html>
<head>
  <style nonce="__CSP_NONCE__">
    body { margin: 0; }
  </style>
</head>
<body>
  <script nonce="__CSP_NONCE__">
    console.log('hello');
  </script>
</body>
</html>

プレースホルダー文字列は何でも構いませんが、HTML 内に偶然出現しない値にするのが安全です (__CSP_NONCE__{{csp-nonce}} など)。

手順 2: nonce を生成する

Web Crypto API で 16 バイトを Base64 URL エンコードします。

// worker/lib/csp.ts
export function generateNonce(): string {
  const bytes = new Uint8Array(16);
  crypto.getRandomValues(bytes);
  return btoa(String.fromCharCode(...bytes))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}

手順 3: HTML レスポンスを書き換える

Worker のエントリポイントで、HTML を返す経路だけ置換処理を挟みます。

// worker/index.ts
import { generateNonce } from './lib/csp';
import { securityHeaders } from './lib/cors';

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const nonce = generateNonce();
    const assetResponse = await env.ASSETS.fetch(request);
    const contentType = assetResponse.headers.get('content-type') ?? '';

    // HTML のみ置換して nonce を焼き込む
    if (contentType.includes('text/html')) {
      const html = await assetResponse.text();
      const replaced = html.replaceAll('__CSP_NONCE__', nonce);
      return new Response(replaced, {
        status: assetResponse.status,
        headers: {
          'Content-Type': 'text/html; charset=utf-8',
          ...securityHeaders(nonce),
        },
      });
    }

    // HTML 以外はパススルー(+ セキュリティヘッダー)
    const headers = new Headers(assetResponse.headers);
    Object.entries(securityHeaders()).forEach(([k, v]) => headers.set(k, v));
    return new Response(assetResponse.body, {
      status: assetResponse.status,
      headers,
    });
  },
};

HTMLRewriter を使う方法もありますが、置換文字列が固定なら replaceAll のほうが直感的です。HTMLRewriter は属性単位で走査するので、こうした固定トークン置換にはオーバーキルになります。

手順 4: CSP ヘッダーを組み立てる

// worker/lib/cors.ts
export function securityHeaders(nonce?: string): Record<string, string> {
  const cspParts = [
    "default-src 'self'",
    nonce ? `script-src 'self' 'nonce-${nonce}'` : "script-src 'self'",
    nonce ? `style-src 'self' 'nonce-${nonce}'` : "style-src 'self'",
    "font-src 'self'",
    "connect-src 'self'",
    "img-src 'self' data:",
    "object-src 'none'",
    "base-uri 'self'",
    "form-action 'self'",
    "frame-ancestors 'none'",
    'upgrade-insecure-requests',
  ];

  return {
    'Content-Security-Policy': cspParts.join('; '),
    'Strict-Transport-Security':
      'max-age=31536000; includeSubDomains; preload',
    'X-Content-Type-Options': 'nosniff',
    'X-Frame-Options': 'DENY',
    'Referrer-Policy': 'strict-origin-when-cross-origin',
    'Permissions-Policy':
      'camera=(), microphone=(), geolocation=(), payment=()',
    'Cross-Origin-Opener-Policy': 'same-origin',
    'Cross-Origin-Embedder-Policy': 'require-corp',
    'Cross-Origin-Resource-Policy': 'same-origin',
  };
}

Cross-Origin-Embedder-Policy: require-corpCross-Origin-Resource-Policy: same-origin が入っていないと securityheaders.com は A 止まりです。A+ を狙うならこの 2 つが必要。

手順 5: デプロイと検証

wrangler deploy

securityheaders.com に URL を投げて A+ であること、および CSP セクションに 'nonce-...' が毎リクエスト異なる値で出ていることを確認します。

curl でも即確認できます。

curl -sI https://your-site.example | grep -i 'content-security-policy'
curl -sI https://your-site.example | grep -i 'content-security-policy'
# 2 回の実行で nonce 値が異なっていれば OK

ハマりどころ

症状 原因 対処
HTML に nonce が入らない run_worker_first が未設定 wrangler.toml に追加
ブラウザで script がブロックされる CSP ヘッダーの nonce と HTML 側の nonce が別値 必ず同じリクエスト内の nonce を両方に使う
インラインイベントハンドラが動かない nonce は onclick 属性には効かない addEventListener に書き換える
A+ にならず A 止まり COEP / CORP 未設定 require-corp + same-origin を追加
プレースホルダーが残る 他 HTML (admin.html 等) で置換忘れ HTML を返すすべての経路で replaceAll を通す

まとめ

  • run_worker_first = true で Static Assets に Worker を噛ませる
  • HTML に __CSP_NONCE__ プレースホルダーを仕込む
  • リクエストごとに nonce を生成し、HTML と CSP ヘッダーに同じ値を焼き込む
  • COEP / CORP を忘れずに入れて A+ へ

実装例は私の自社プロダクト 796f75617265686f6d65.com で稼働しています。worker/index.tsworker/lib/cors.ts にほぼこのままのコードが入っており、GitHub 公開リポジトリから全体を読めます。


796f75617265686f6d65.com

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?