@k8ordo/framework

CSP

Content-Security-Policyで、ページが実行するスクリプトを制限できるようになります。

このページの内容

フレームワークのスクリプト

フレームワークはポリシーを決めません。フレームワークがHTMLに書くインラインスクリプトを、ハッシュかnonceで許可できるようにするだけです。対象は、ハイドレーションのためにHTMLへ埋め込むペイロードと、Reactのスクリプトです。

staticモード

staticモードでは、ポリシーをframework()のcspオプションに渡し、各ページの<meta>に書きます。静的なファイルはリクエストごとに変わらないので、nonceは使えません。nonce()はserverモードでだけ使えます。

cspオプション

vite.config.ts
import { 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.ts
import { 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.ts
import { 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.tsx
import { 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付きのスクリプトを差し込めます。

k8ordo

Baselineに入った機能を、制限なく使うReactのライブラリ群。

© 2026 k8o — MIT License

組版:Noto Sans JP / M PLUS 2