はじめに
Content Security Policy(CSP)は、Webアプリケーションのセキュリティを強化するための重要なHTTPヘッダーです。この記事では、Next.js App RouterでCSPを適切に設定し、XSS攻撃やデータインジェクション攻撃から保護する方法を解説します。
CSPの基本
CSPディレクティブの種類
| ディレクティブ | 説明 |
|---|---|
| default-src | フォールバックポリシー |
| script-src | JavaScriptの読み込み元 |
| style-src | CSSの読み込み元 |
| img-src | 画像の読み込み元 |
| font-src | フォントの読み込み元 |
| connect-src | fetch/XHR/WebSocketの接続先 |
| frame-src | iframe/frameの読み込み元 |
| object-src | プラグイン(Flash等)の読み込み元 |
Middlewareでの動的CSP設定
Nonce付きCSPの実装
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// 暗号学的に安全なnonceを生成
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
// CSPポリシーの構築
const cspHeader = `
default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
style-src 'self' 'nonce-${nonce}';
img-src 'self' blob: data: https:;
font-src 'self' https://fonts.gstatic.com;
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
upgrade-insecure-requests;
`.replace(/\s{2,}/g, ' ').trim();
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-nonce', nonce);
const response = NextResponse.next({
request: {
headers: requestHeaders,
},
});
response.headers.set('Content-Security-Policy', cspHeader);
return response;
}
export const config = {
matcher: [
{
source: '/((?!api|_next/static|_next/image|favicon.ico).*)',
missing: [
{ type: 'header', key: 'next-router-prefetch' },
{ type: 'header', key: 'purpose', value: 'prefetch' },
],
},
],
};
Nonceの使用
// app/layout.tsx
import { headers } from 'next/headers';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const headersList = headers();
const nonce = headersList.get('x-nonce') ?? '';
return (
<html lang="ja">
<head>
{/* nonceを指定したインラインスクリプト */}
<script
nonce={nonce}
dangerouslySetInnerHTML={{
__html: `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
`,
}}
/>
</head>
<body>{children}</body>
</html>
);
}
next.config.jsでの静的CSP設定
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.googletagmanager.com https://www.google-analytics.com",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"img-src 'self' data: https: blob:",
"font-src 'self' https://fonts.gstatic.com",
"connect-src 'self' https://www.google-analytics.com https://api.example.com",
"frame-src 'self' https://www.youtube.com",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
].join('; '),
},
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'X-Frame-Options',
value: 'DENY',
},
{
key: 'X-XSS-Protection',
value: '1; mode=block',
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin',
},
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=()',
},
{
key: 'Strict-Transport-Security',
value: 'max-age=31536000; includeSubDomains; preload',
},
],
},
];
},
};
module.exports = nextConfig;
CSP違反のレポート
Report-Onlyモードでのテスト
// middleware.ts
export function middleware(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
// 本番用CSP
const cspHeader = `
default-src 'self';
script-src 'self' 'nonce-${nonce}';
style-src 'self' 'nonce-${nonce}';
report-uri /api/csp-report;
`.replace(/\s{2,}/g, ' ').trim();
// テスト用(ブロックせずレポートのみ)
const cspReportOnlyHeader = `
default-src 'self';
script-src 'self' 'nonce-${nonce}';
report-uri /api/csp-report;
`.replace(/\s{2,}/g, ' ').trim();
const response = NextResponse.next();
// 本番では Content-Security-Policy を使用
response.headers.set('Content-Security-Policy', cspHeader);
// テスト時は Report-Only を使用
// response.headers.set('Content-Security-Policy-Report-Only', cspReportOnlyHeader);
return response;
}
CSP違反レポートのAPI
// app/api/csp-report/route.ts
import { NextRequest, NextResponse } from 'next/server';
interface CSPViolationReport {
'csp-report': {
'document-uri': string;
'violated-directive': string;
'effective-directive': string;
'original-policy': string;
'blocked-uri': string;
'source-file'?: string;
'line-number'?: number;
'column-number'?: number;
};
}
export async function POST(request: NextRequest) {
try {
const report: CSPViolationReport = await request.json();
const violation = report['csp-report'];
// ログに記録
console.error('CSP Violation:', {
documentUri: violation['document-uri'],
violatedDirective: violation['violated-directive'],
blockedUri: violation['blocked-uri'],
sourceFile: violation['source-file'],
lineNumber: violation['line-number'],
});
// 外部サービスに送信(オプション)
// await sendToMonitoringService(violation);
return NextResponse.json({ status: 'reported' });
} catch (error) {
console.error('CSP Report Error:', error);
return NextResponse.json(
{ error: 'Failed to process report' },
{ status: 400 }
);
}
}
サードパーティスクリプトの対応
Google Analytics / Tag Manager
// components/Analytics.tsx
'use client';
import Script from 'next/script';
interface AnalyticsProps {
nonce: string;
gaId: string;
}
export function Analytics({ nonce, gaId }: AnalyticsProps) {
return (
<>
<Script
src={`https://www.googletagmanager.com/gtag/js?id=${gaId}`}
strategy="afterInteractive"
nonce={nonce}
/>
<Script
id="google-analytics"
strategy="afterInteractive"
nonce={nonce}
dangerouslySetInnerHTML={{
__html: `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${gaId}', {
page_path: window.location.pathname,
});
`,
}}
/>
</>
);
}
動的スクリプトローダー
// lib/script-loader.ts
export function loadScript(
src: string,
nonce: string,
options?: {
async?: boolean;
defer?: boolean;
onLoad?: () => void;
onError?: (error: Error) => void;
}
): Promise<void> {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
script.nonce = nonce;
if (options?.async) script.async = true;
if (options?.defer) script.defer = true;
script.onload = () => {
options?.onLoad?.();
resolve();
};
script.onerror = () => {
const error = new Error(`Failed to load script: ${src}`);
options?.onError?.(error);
reject(error);
};
document.head.appendChild(script);
});
}
環境別CSP設定
// lib/csp.ts
type Environment = 'development' | 'staging' | 'production';
interface CSPConfig {
defaultSrc: string[];
scriptSrc: string[];
styleSrc: string[];
imgSrc: string[];
connectSrc: string[];
fontSrc: string[];
frameSrc: string[];
}
const baseConfig: CSPConfig = {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'"],
imgSrc: ["'self'", 'data:', 'blob:'],
connectSrc: ["'self'"],
fontSrc: ["'self'"],
frameSrc: ["'none'"],
};
const envConfigs: Record<Environment, Partial<CSPConfig>> = {
development: {
scriptSrc: ["'self'", "'unsafe-eval'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'"],
connectSrc: ["'self'", 'ws://localhost:*'],
},
staging: {
scriptSrc: ["'self'", 'https://www.googletagmanager.com'],
connectSrc: ["'self'", 'https://staging-api.example.com'],
},
production: {
scriptSrc: ["'self'", 'https://www.googletagmanager.com'],
styleSrc: ["'self'", 'https://fonts.googleapis.com'],
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
connectSrc: ["'self'", 'https://api.example.com', 'https://www.google-analytics.com'],
},
};
export function generateCSP(env: Environment, nonce?: string): string {
const config = { ...baseConfig, ...envConfigs[env] };
const nonceValue = nonce ? `'nonce-${nonce}'` : '';
const directives = [
`default-src ${config.defaultSrc.join(' ')}`,
`script-src ${config.scriptSrc.join(' ')} ${nonceValue}`.trim(),
`style-src ${config.styleSrc.join(' ')} ${nonceValue}`.trim(),
`img-src ${config.imgSrc.join(' ')}`,
`connect-src ${config.connectSrc.join(' ')}`,
`font-src ${config.fontSrc.join(' ')}`,
`frame-src ${config.frameSrc.join(' ')}`,
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
];
if (env === 'production') {
directives.push('upgrade-insecure-requests');
}
return directives.join('; ');
}
CSPテストユーティリティ
// __tests__/csp.test.ts
import { generateCSP } from '@/lib/csp';
describe('CSP Generator', () => {
it('should generate development CSP with unsafe-eval', () => {
const csp = generateCSP('development');
expect(csp).toContain("'unsafe-eval'");
expect(csp).toContain("'unsafe-inline'");
});
it('should generate production CSP without unsafe directives', () => {
const csp = generateCSP('production');
expect(csp).not.toContain("'unsafe-eval'");
expect(csp).toContain('upgrade-insecure-requests');
});
it('should include nonce when provided', () => {
const nonce = 'test-nonce-123';
const csp = generateCSP('production', nonce);
expect(csp).toContain(`'nonce-${nonce}'`);
});
it('should include google tag manager in production', () => {
const csp = generateCSP('production');
expect(csp).toContain('https://www.googletagmanager.com');
});
});
トラブルシューティング
よくある問題と解決策
// CSPエラーデバッグユーティリティ
export function debugCSPViolation() {
if (typeof window === 'undefined') return;
document.addEventListener('securitypolicyviolation', (e) => {
console.group('🔒 CSP Violation');
console.log('Violated directive:', e.violatedDirective);
console.log('Effective directive:', e.effectiveDirective);
console.log('Blocked URI:', e.blockedURI);
console.log('Document URI:', e.documentURI);
console.log('Source file:', e.sourceFile);
console.log('Line number:', e.lineNumber);
console.log('Column number:', e.columnNumber);
console.groupEnd();
});
}
// 開発時のみ有効化
if (process.env.NODE_ENV === 'development') {
debugCSPViolation();
}
| 問題 | 原因 | 解決策 |
|---|---|---|
| インラインスクリプトがブロック | nonce未設定 | script要素にnonce属性を追加 |
| 外部スクリプトがブロック | script-srcに未登録 | ドメインをscript-srcに追加 |
| スタイルが適用されない | style-srcの設定不足 | フォントCDNをstyle-srcに追加 |
| API呼び出し失敗 | connect-srcの設定不足 | APIドメインをconnect-srcに追加 |
まとめ
効果的なCSP設定のポイントをまとめます。
- Nonce使用: 動的なnonceでインラインスクリプトを安全に許可
- 段階的導入: Report-Onlyモードでテスト後に本番適用
- 最小権限の原則: 必要最小限のソースのみを許可
- 環境別設定: 開発・本番で適切なポリシーを使い分け
- モニタリング: 違反レポートを監視して問題を早期発見