▲ Next.jsでCSP Level 3を実装する
この記事で学べること:
- Next.jsのミドルウェアとは何か
- なぜミドルウェアでCSPを設定するのか
- nonceの生成と管理方法
- コンポーネントへのnonce伝達方法
- 実践的な実装例とベストプラクティス
🤔 Next.jsのミドルウェアとは?
ミドルウェアの基本概念
Next.jsのミドルウェアは、リクエストが完了する前に実行されるコードです。サーバーとページの間に位置し、リクエストとレスポンスを加工できます。
リクエスト送信
リクエスト/レスポンス処理
実際の処理
ミドルウェアでできること
- ✅ リクエストヘッダーの読み取り・変更
- ✅ レスポンスヘッダーの設定(CSPなど)
- ✅ リダイレクトやリライト
- ✅ 認証・認可のチェック
- ✅ ロギングや分析
⚠️ 重要な制限:
ミドルウェアは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}';
📊 まとめ
実装のポイント
- ミドルウェアでリクエストごとに新しいnonceを生成
- レスポンスヘッダー経由でnonceを伝達
- Server Componentで
headers()
からnonce取得 - Client ComponentにはPropsでnonce伝達
- strict-dynamicで動的スクリプトも安全に実行
CSP Level 3の実装により、XSS攻撃を効果的に防ぎながら、Next.jsの動的な機能(コード分割、動的インポートなど)を維持できます。セキュリティと開発体験のバランスを取る最適な方法です。