React RouterでStrict CSPに対応する

React Router v7でドメインではなくノンスを使ったStrict CSPを実装した。

ReactおよびReact Routerが生成する<script>タグに対してnonce属性を付与するには、以下の4箇所にnonceを指定する必要があった。

  • <ScrollRestoration />
  • <Scripts />
  • <ServerRouter />
  • renderToPipeableStream()

これらに付与しつつ、Content-Security-Policyヘッダーにノンスを設定する必要があった。

root.tsx

export function Layout({ children }) {
  const nonce = useContext(NonceContext);

  return (
    <html lang="ja">
      <head>
        {/* snip */}
      </head>
      <body>
        {children}
        <ScrollRestoration nonce={nonce} />
        <Scripts nonce={nonce} />
      </body>
    </html>
  );
}
  • root.tsxにある<ScrollRestoration /><Scripts />はReact Routerが提供する<script>タグを生成しているが、これらのコンポーネントにはnonceを渡せるようになっており、渡すとレンダリングされた<script>タグにnonce属性が設定される。
  • ノンスは下で見るようにentry.server.tsxで生成する必要があるため、コンポーネントで受け取るためにuseContextを利用している。

entry.server.tsx

export default function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  routerContext: EntryContext,
  loadContext: AppLoadContext
) {
  // ...

  const nonce = generateNonce();
  responseHeaders.set("Content-Security-Policy", [
    "default-src 'self';",
    `script-src 'nonce-${nonce}' 'strict-dynamic';`,
    "style-src 'self' 'unsafe-inline';",
    "object-src 'none';",
    "base-uri 'none';",
  ].join(""));

  const { pipe, abort } = renderToPipeableStream(
    <NonceContext.Provider value={nonce}>
      <ServerRouter
        context={routerContext}
        url={request.url}
        nonce={nonce}
      />
    </NonceContext.Provider>,
    {
      // ...
      nonce,
    },
  );

  setTimeout(abort, streamTimeout + 1000);
}
  • <ServerRouter />はコンポーネントをサーバーでレンダリングする際のエントリーポイントになっているコンポーネント。ドキュメントには記載がないが、実はここにもnonceを渡せるようになっており、渡さないとエラーが出る。
  • renderToPipeableStream()はReactが提供するAPIで、ReactがHTMLをストリーミングするために使う。<Suspense>fallback属性で指定したコンポーネントを本来のコンポーネントに置き換えるためにインラインの<script>タグが使われる(参考)ため、ここにもnonce属性が必要になるが、引数にnonceを渡すことで解決する。
  • Content-Security-Policyヘッダーにnonceを設定することでStrict CSPが有効になる。