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 構成 -
wranglerv3 以降
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-corp と Cross-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.ts と worker/lib/cors.ts にほぼこのままのコードが入っており、GitHub 公開リポジトリから全体を読めます。