Next.jsでCSP Level 3を実装する

この記事で学べること:
  • Next.jsのミドルウェアとは何か
  • なぜミドルウェアでCSPを設定するのか
  • nonceの生成と管理方法
  • コンポーネントへのnonce伝達方法
  • 実践的な実装例とベストプラクティス

🤔 Next.jsのミドルウェアとは?

ミドルウェアの基本概念

Next.jsのミドルウェアは、リクエストが完了する前に実行されるコードです。サーバーとページの間に位置し、リクエストとレスポンスを加工できます。

🌐
ブラウザ
リクエスト送信
⚙️
ミドルウェア
リクエスト/レスポンス処理
📄
ページ/API
実際の処理

ミドルウェアでできること

⚠️ 重要な制限:

ミドルウェアはEdge Runtimeで実行されるため、Node.js APIの一部(fs、pathなど)は使用できません。

基本的なミドルウェアの例

middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  // リクエストのURLを取得
  console.log('リクエストURL:', request.url)
  
  // レスポンスを作成
  const response = NextResponse.next()
  
  // カスタムヘッダーを追加
  response.headers.set('X-Custom-Header', 'Hello from Middleware!')
  
  return response
}

// ミドルウェアを適用するパスを指定
export const config = {
  matcher: '/((?!api|_next/static|_next/image|favicon.ico).*)',
}

🛡️ なぜミドルウェアでCSPを設定するのか?

CSP実装の3つの方法

❌ 方法1: メタタグ
⚠️ 方法2: next.config.js
✅ 方法3: ミドルウェア

❌ メタタグでの設定(非推奨)

<meta 
  http-equiv="Content-Security-Policy" 
  content="script-src 'nonce-abc123' 'strict-dynamic';"
/>
問題点:
  • HTTPヘッダーより優先度が低い
  • 一部のCSPディレクティブが動作しない
  • 動的なnonce生成が困難

⚠️ next.config.jsでの設定(制限あり)

module.exports = {
  async headers() {
    return [{
      source: '/(.*)',
      headers: [{
        key: 'Content-Security-Policy',
        value: "script-src 'self' 'strict-dynamic';" // 静的な値のみ
      }],
    }]
  },
}
問題点:
  • nonceを動的に生成できない(毎回同じ値)
  • リクエストごとの処理ができない

✅ ミドルウェアでの設定(推奨)

export function middleware(request: NextRequest) {
  const nonce = generateNonce() // リクエストごとに新しいnonce
  
  const cspHeader = `
    script-src 'nonce-${nonce}' 'strict-dynamic';
    object-src 'none';
    base-uri 'none';
  `.replace(/\s{2,}/g, ' ').trim()
  
  const response = NextResponse.next()
  response.headers.set('Content-Security-Policy', cspHeader)
  
  return response
}
利点:
  • リクエストごとに新しいnonceを生成
  • 動的な処理が可能
  • すべてのレスポンスに適用

🎲 nonceの実装

Step 1: nonceの生成

middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

// cryptoはEdge Runtimeでも利用可能
function generateNonce(): string {
  const array = new Uint8Array(16)
  crypto.getRandomValues(array)
  return Buffer.from(array).toString('base64')
}

export function middleware(request: NextRequest) {
  const nonce = generateNonce()
  
  // CSPヘッダーを構築
  const cspHeader = `
    default-src 'self';
    script-src '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(/\s{2,}/g, ' ').trim()
  
  const response = NextResponse.next()
  
  // レスポンスヘッダーにCSPを設定
  response.headers.set('Content-Security-Policy', cspHeader)
  
  // nonceをレスポンスヘッダーに含める(後で取得するため)
  response.headers.set('x-nonce', nonce)
  
  return response
}
💡 なぜBuffer.from().toString('base64')を使うのか?

base64エンコードにより、ランダムなバイト列を安全にHTTPヘッダーやHTML属性で使える文字列に変換します。

Step 2: Server Componentでnonceを取得

app/layout.tsx
import { headers } from 'next/headers'
import Script from 'next/script'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  // Server Componentでヘッダーからnonceを取得
  const nonce = headers().get('x-nonce') || ''
  
  return (
    <html lang="ja">
      <head>
        {/* nonceを使ったインラインスタイル */}
        <style nonce={nonce}>
          {`
            body {
              margin: 0;
              font-family: system-ui, sans-serif;
            }
          `}
        </style>
      </head>
      <body>
        {children}
        
        {/* Next.jsのScriptコンポーネントにnonceを渡す */}
        <Script
          src="/analytics.js"
          strategy="afterInteractive"
          nonce={nonce}
        />
        
        {/* インラインスクリプトの例 */}
        <script nonce={nonce}>
          {`
            console.log('This inline script has nonce!');
          `}
        </script>
      </body>
    </html>
  )
}

Step 3: Client Componentへのnonce伝達

⚠️ 重要:

Client Componentではheaders()を直接使えません。Server ComponentからPropsとして渡す必要があります。

app/components/ClientAnalytics.tsx
'use client'

import { useEffect } from 'react'
import Script from 'next/script'

interface ClientAnalyticsProps {
  nonce: string
}

export default function ClientAnalytics({ nonce }: ClientAnalyticsProps) {
  useEffect(() => {
    // Client Componentでの動的スクリプト生成
    const script = document.createElement('script')
    script.nonce = nonce
    script.textContent = `
      console.log('Dynamically created script with nonce!');
    `
    document.body.appendChild(script)
  }, [nonce])
  
  return (
    <>
      {/* Next.jsのScriptコンポーネント */}
      <Script
        id="google-analytics"
        strategy="afterInteractive"
        nonce={nonce}
      >
        {`
          window.dataLayer = window.dataLayer || [];
          function gtag(){dataLayer.push(arguments);}
          gtag('js', new Date());
          gtag('config', 'GA_MEASUREMENT_ID');
        `}
      </Script>
    </>
  )
}
app/page.tsx
import { headers } from 'next/headers'
import ClientAnalytics from './components/ClientAnalytics'

export default function Page() {
  const nonce = headers().get('x-nonce') || ''
  
  return (
    <main>
      <h1>My Next.js App with CSP</h1>
      
      {/* Client Componentにnonceを渡す */}
      <ClientAnalytics nonce={nonce} />
    </main>
  )
}

🎮 インタラクティブデモ

CSP Level 3の動作確認デモ

以下のボタンをクリックして、nonceありとなしのスクリプト実行を確認してください:

// デモコンソール - ボタンをクリックしてください

⚡ ベストプラクティス

1. 開発環境と本番環境の分離

const isDev = process.env.NODE_ENV === 'development'

const cspHeader = isDev 
  ? "default-src * 'unsafe-inline' 'unsafe-eval';" // 開発環境は緩く
  : `script-src 'nonce-${nonce}' 'strict-dynamic';` // 本番は厳格に

2. nonceのContext化(大規模アプリ向け)

// app/contexts/NonceContext.tsx
'use client'

import { createContext, useContext } from 'react'

const NonceContext = createContext<string>('')

export const NonceProvider = ({ 
  children, 
  nonce 
}: { 
  children: React.ReactNode
  nonce: string
}) => (
  <NonceContext.Provider value={nonce}>
    {children}
  </NonceContext.Provider>
)

export const useNonce = () => useContext(NonceContext)

🚨 よくある問題と解決策

問題1: Next.jsの内部スクリプトがブロックされる

解決策: strict-dynamicを使用することで、Next.jsが動的に生成するスクリプトも許可されます。

問題2: サードパーティスクリプトが動作しない

解決策: Next.jsのScriptコンポーネントを使用し、nonceを渡します。

<Script
  src="https://example.com/script.js"
  strategy="lazyOnload"
  nonce={nonce}
/>

問題3: スタイルがブロックされる

解決策: style-srcにもnonceを追加します。

style-src 'self' 'nonce-${nonce}';

📊 まとめ

実装のポイント

  1. ミドルウェアでリクエストごとに新しいnonceを生成
  2. レスポンスヘッダー経由でnonceを伝達
  3. Server Componentheaders()からnonce取得
  4. Client ComponentにはPropsでnonce伝達
  5. strict-dynamicで動的スクリプトも安全に実行

CSP Level 3の実装により、XSS攻撃を効果的に防ぎながら、Next.jsの動的な機能(コード分割、動的インポートなど)を維持できます。セキュリティと開発体験のバランスを取る最適な方法です。