CSP
Content-Security-Policyで、ページが実行するスクリプトを制限できるようになります。
このページの内容
フレームワークのスクリプト
フレームワークはポリシーを決めません。フレームワークがHTMLに書くインラインスクリプトを、ハッシュかnonceで許可できるようにするだけです。対象は、ハイドレーションのためにHTMLへ埋め込むペイロードと、Reactのスクリプトです。
staticモード
staticモードでは、ポリシーをframework()のcspオプションに渡し、各ページの<meta>に書きます。静的なファイルはリクエストごとに変わらないので、nonceは使えません。nonce()はserverモードでだけ使えます。
cspオプション
vite.config.tsimport { framework } from '@k8ordo/framework/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
framework({
mode: 'static',
csp: {
'script-src': ["'self'"],
'object-src': ["'none'"],
'base-uri': ["'none'"],
},
}),
],
});cspには、ディレクティブごとにソースの配列を渡します。各ページの<head>の先頭に<meta http-equiv="Content-Security-Policy">が書かれます。cspを渡さなければ<meta>は書かれません。
ビルドは、フレームワークのインラインスクリプトのハッシュをページごとにscript-srcへ足します。
default-srcだけを書いたときは、そこからscript-srcを作って足します。script-src-elemを書いたときは、そちらにも足します。どれも書いていなければスクリプトは制限されないので、ハッシュは足しません。
アプリのスクリプト
vite.config.tsimport { colorSchemeScriptHash } from '@k8ordo/color-scheme';
import { framework } from '@k8ordo/framework/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
framework({
mode: 'static',
csp: {
'script-src': ["'self'", await colorSchemeScriptHash()],
'object-src': ["'none'"],
'base-uri': ["'none'"],
},
}),
],
});アプリ自身のインラインスクリプトは、ハッシュをポリシーに書いて許可します。10行目のcolorSchemeScriptHash()が、@k8ordo/color-schemeのスクリプトのハッシュを返します。
ColorSchemeProviderにdefaultPreferenceを渡しているときは、colorSchemeScriptHash()にも同じ値を渡します。defaultPreferenceの値でスクリプトの中身とハッシュが変わります。
ハッシュを書いていないインラインスクリプトは、すべてブロックされます。コンテンツに注入されたスクリプトも実行されません。
エラーになるポリシー
フレームワークのモジュールのスクリプトは'self'で許可します。'strict-dynamic'があるとブラウザは'self'を無視します。そのためscript-src(無ければdefault-src)かscript-src-elemに'strict-dynamic'があるとエラーになります。
frame-ancestorsとreport-uri、sandboxもエラーになります。<meta>に書いても効かないディレクティブなので、ホスティングのヘッダーで設定してください。
落とし穴
エラーになるのはframework()を呼んだ時点です。エラー文はthe "csp" option cannot go into a page's <meta> as it is:で始まり、問題を1行ずつ挙げます。
serverモード
serverモードでは、応答ごとに新しいnonceを作り、ルートのguard.tsでポリシーをヘッダーに書きます。
guard.tsのポリシー
src/routes/guard.tsimport { nonce, responseHeaders } from '@k8ordo/framework/server';
export default function guard() {
const scripts = [`'nonce-${nonce()}'`, "'strict-dynamic'"];
if (import.meta.env.DEV) scripts.push("'unsafe-eval'");
const policy = [
`script-src ${scripts.join(' ')}`,
"object-src 'none'",
"base-uri 'none'",
].join('; ');
responseHeaders().set('content-security-policy', policy);
}@k8ordo/framework/serverのnonce()が、その応答のnonceを返します。4行目でポリシーに書き、11行目でヘッダーに設定します。
メモ
5行目は、vite devのときだけ'unsafe-eval'を足します。Reactの開発ビルドはeval()を使います。無いと、ページを開くたびにコンソールにエラーが出ます。本番のビルドはeval()を使わず、この行もビルドで消えます。
ルートのguard.tsは、ページとペイロード、Server Actionとroute.tsの応答の前に実行されます。どのルートにも一致しないURLの404にも、同じポリシーが付きます。
nonceはモジュールのスクリプトにも付きます。'strict-dynamic'を書くと、そのスクリプトが残りのクライアントのコードを読み込めます。
nonceを付けるスクリプト
src/routes/layout.tsximport { ColorSchemeProvider } from '@k8ordo/color-scheme';
import { nonce } from '@k8ordo/framework/server';
import type { ReactNode } from 'react';
export default function RootLayout({
children,
}: {
children: ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ColorSchemeProvider nonce={nonce()}>
{children}
</ColorSchemeProvider>
</body>
</html>
);
}アプリ自身のインラインスクリプトには、同じnonceを付けます。@k8ordo/color-schemeなら、ルートレイアウトでColorSchemeProviderにnonce={nonce()}を渡します。
nonce()は、リクエストに応答している間ならどこで呼んでも同じ値を返します。描画の中でも呼べます。
リクエストの外、たとえばモジュールのトップレベルで呼ぶと、nonce() needs a requestで始まるエラーになります。
nonceとキャッシュ
nonceを含む応答は、CDNなどの共有キャッシュに置かないでください。同じnonceを何度も配信すると、それを知った攻撃者がnonce付きのスクリプトを差し込めます。