🛡️

Next.js 15 App Router で CSP対応

に公開

最近、Next.js App Router で CSP 対応したので、備忘として書こうと思います。

Next.js のチュートリアルと Google が推奨する Strict CSP に基づいて、nonce と strict-dynamic を利用した Next.js での CSP 対応手順について説明します。

Strict CSP について、詳細を知りたい場合には、以下の記事を参照してください。
https://web.dev/articles/strict-csp?hl=ja

Next.js の CSP 対応の公式チュートリアルはこちら。
https://nextjs.org/docs/app/guides/content-security-policy

まず、CSP とはなにか

コンテンツ セキュリティ ポリシー(CSP)は、XSS の軽減に役立つ追加のセキュリティ レイヤです。CSP を構成するには、Content-Security-Policy HTTP ヘッダーをウェブページに追加し、そのページに対してユーザー エージェントが読み込むことができるリソースを制御する値を設定します。

簡単にいうと Web サイトを運営者の意図しないリソース(スクリプト、スタイルシート、画像、フォントなど)の読み込みをしないようにする仕組みであり、そのためのホワイトリストを設定する仕組みです。

Next.js での対応手順

  1. middleware の修正。
    middleware.ts に CSP の設定を記述し、それをレスポンスヘッダーにセットします。また、ページコンポーネントで Nonce を利用できるよう、リクエストヘッダーにもその値を渡します。
import { NextRequest, NextResponse } from "next/server";

export function middleware(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
  const cspHeader = `
    default-src 'self';
    script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
    style-src 'self' 'nonce-${nonce}';
    img-src 'self' blob: data:;
    font-src 'self';
    object-src 'none';
    base-uri 'self';
    form-action 'self';
    frame-ancestors 'none';
    upgrade-insecure-requests;
`;
  // Replace newline characters and spaces
  const contentSecurityPolicyHeaderValue = cspHeader
    .replace(/\s{2,}/g, " ")
    .trim();

  const requestHeaders = new Headers(request.headers);
  requestHeaders.set("x-nonce", nonce);

  requestHeaders.set(
    "Content-Security-Policy",
    contentSecurityPolicyHeaderValue
  );

  const response = NextResponse.next({
    request: {
      headers: requestHeaders,
    },
  });
  response.headers.set(
    "Content-Security-Policy",
    contentSecurityPolicyHeaderValue
  );

  return response;
}
  1. nonce を読み取りスクリプトへ明記。
import { headers } from "next/headers";
import Script from "next/script";

export default async function Page() {
  const nonceHeader = (await headers()).get("x-nonce");
  const nonce = nonceHeader === null ? undefined : nonceHeader;

  return (
    <Script
      src="https://www.googletagmanager.com/gtag/js"
      strategy="afterInteractive"
      nonce={nonce}
    />
  );
}
  1. ビルドして起動する。
npm run build
npm run start
  1. ソースコードを確認する。
    nonce が付与されていることを確認。

実は明示的に nonce を渡さなくてもセットされる。

これは検証してたところ、気づいたのですが、Next.js が提供する Script タグを利用しており、かつ Dynamic Rendering であれば、公式ドキュメントに明記されているように明示的に nonce の値を読み取り、受け渡さなくても自動的に nonce の値がセットされるようでした。

  1. nonce={nonce}の記述を外して、Dynamic Rendering を強制する記述を追加する。
import Script from "next/script";

export const dynamic = "force-dynamic"; // Dynamic Rendering を強制

export default async function Page() {
  return (
    <Script
      src="https://www.googletagmanager.com/gtag/js"
      strategy="afterInteractive"
    />
  );
}
  1. ビルドして起動する。
npm run build
npm run start
  1. ソースコードを確認する。
    nonce が付与されていることを確認。

Scriptタグ内の挙動

Scriptタグのソースコードを見てみると、SSR 時に props での nonce の引き渡しがない場合にHeadManagerContextという Context から値を自動取得しているようです。

/**
 * Load a third-party scripts in an optimized way.
 *
 * Read more: [Next.js Docs: `next/script`](https://nextjs.org/docs/app/api-reference/components/script)
 */ function Script(props) {
    const { id, src = '', onLoad = ()=>{}, onReady = null, strategy = 'afterInteractive', onError, stylesheets, ...restProps } = props;
    // Context is available only during SSR
    let { updateScripts, scripts, getIsSsr, appDir, nonce } = (0, _react.useContext)(_headmanagercontextsharedruntime.HeadManagerContext);
    // if a nonce is explicitly passed to the script tag, favor that over the automatic handling
    nonce = restProps.nonce || nonce;

現状だと nonce を明示的にセットしなくても、自動的に値がセットされますが、継続的にこの仕様で動作するのかわからないので、基本的には公式ドキュメント通りに対応しておいたほうが安心だと思います。

Static Rendering を利用したい場合にはどうすればよいのか?

Next.js の公式ドキュメントを見ると、Static Rendering の場合には、動的に値を取得ができないため、nonce を利用した CSP 対応はできないと記載されています。

Static Rendering 時の挙動を検証

こちら念の為、実際に検証をしてみました。
先程のソースコードの強制的に Dynamic Rendering にするソースを外して、再度ビルドと起動をします。

  1. ソースコードから、export const dynamic = "force-dynamic";を除去し、Static Rendering にする。
import Script from "next/script";

export default async function Page() {
  return (
    <Script
      src="https://www.googletagmanager.com/gtag/js"
      strategy="afterInteractive"
    />
  );
}
  1. ビルドして起動する。
npm run build
npm run start
  1. ソースコードとブラウザを確認する。
    script タグに nonce の値がセットされていない状態になる。

ソースコード上、script タグに nonce の値がセットされておらず、Content-Security-Policy で指定している内容と一致しないため、エラーになります。

Dynamic Rendering と Static Rendering を両立しつつ、CSP 対応はできない

公式では、全てのページが Dynamic Rendering でないと nonce 対応できないと記載されているため、現状はできないようです。
しかし、もともと Dynamic Rendering と Static Rendering が混在する形で作っていて、後から CSP 対応するってなった人に対して、この仕様だと正直困るケースが多いと思います。

Subresource Integrity が正式リリースされればそれを利用するのが良いかも

現在、Next.js は Subresource Integrity という、ハッシュを利用した CSP 対応の仕組みを開発中のようです。

https://nextjs.org/docs/app/guides/content-security-policy#subresource-integrity-experimental

正式リリースされたら、Dynamic Rendering と Static Rendering が混在するようなプロダクトに関しては、こちらを利用するほうが望ましいと思われます。

H&Companyテックブログ

Discussion