@k8ordo/color-scheme

CSP

Provider が最初の描画の前に走らせるのは、インラインスクリプトです。スクリプトを制限する Content-Security-Policy の下では、nonce かハッシュで許さなければ実行されません。@k8ordo/server では応答ごとの nonce で、@k8ordo/static ではハッシュで許します。

許さないとどうなるか

ブラウザはスクリプトを実行せず、ポリシー違反をコンソールに出します。ページが壊れるわけではなく、hydrate のあとで Provider の effect がクラスを書くので、最後には正しい表示になります。ただ、それまでは既定値で描かれるので、ダークを選んだ訪問者にはライトの画面が一瞬光ります。スクリプトが防ぐはずのものです。

'unsafe-inline' を足しても動きますが、ページに紛れ込んだほかのインラインスクリプトもすべて動くようになり、ポリシーを書いた意味がなくなります。このスクリプトだけを、nonce かハッシュで名指して許します。

@k8ordo/server では nonce で許す

@k8ordo/server は応答ごとに新しい nonce を作り、フレームワーク自身のインラインスクリプトに付けます。@k8ordo/server/runtime の nonce() がその値を返すので、ルートの guard.ts でそれを名指すポリシーをヘッダーに書き、ルートレイアウトで同じ値を Provider の nonce に渡します。

ts
// src/routes/guard.ts (@k8ordo/server)
import { nonce, responseHeaders } from '@k8ordo/server/runtime';

export default function guard() {
  responseHeaders().set(
    'content-security-policy',
    `script-src 'nonce-${nonce()}' 'strict-dynamic'; object-src 'none'; base-uri 'none'`,
  );
}
tsx
// src/routes/layout.tsx (@k8ordo/server)
import { ColorSchemeProvider } from '@k8ordo/color-scheme';
import { nonce } from '@k8ordo/server/runtime';
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() はレイアウトの描画の中でも読めます。スクリプトに署名することは応答を書くことではないからです。フレームワークの起動のモジュールにも同じ nonce が付くので、'strict-dynamic' の下でも残りのクライアントが読み込まれます。nonce は新しいうちしか意味が無いので、nonce を持つ応答は共有キャッシュに置きません。

@k8ordo/server: ガードで Content-Security-Policy を書く

@k8ordo/static ではハッシュで許す

ファイルは誰が読んでも同じなので、nonce を持てません。colorSchemeScriptHash() はスクリプトの SHA-256 を CSP のソース('sha256-…')として返す Promise です。vite.config.ts で待ち、framework() の csp オプションの script-src に入れます。フレームワークは自分のインラインスクリプトのハッシュを、ページごとにそこへ足します。

ts
// vite.config.ts (@k8ordo/static)
import { colorSchemeScriptHash } from '@k8ordo/color-scheme';
import { framework } from '@k8ordo/static';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [
    framework({
      csp: {
        'script-src': ["'self'", await colorSchemeScriptHash()],
        'object-src': ["'none'"],
        'base-uri': ["'none'"],
      },
    }),
  ],
});

ハッシュは値を書き写さず、設定の中で毎回計算します。スクリプトの文字列はインストールしたバージョンのもので、更新で変わることがあり、計算していればポリシーとスクリプトがずれません。

@k8ordo/static: ハッシュで書く Content-Security-Policy

同じ defaultPreference を渡す

スクリプトは Provider の既定値を文字列に埋め込むので、ハッシュは既定値ごとに違います。Provider に defaultPreference を渡しているなら、colorSchemeScriptHash() にも同じ値を渡します。nonce で許すときは、既定値に関係なく同じ書き方で済みます。

tsx
// src/routes/layout.tsx
<ColorSchemeProvider defaultPreference="dark">{children}</ColorSchemeProvider>

// vite.config.ts
'script-src': ["'self'", await colorSchemeScriptHash('dark')],