Documentation Next.js

はじめに

Content Security Policy(CSP)は、Webアプリケーションのセキュリティを強化するための重要なHTTPヘッダーです。この記事では、Next.js App RouterでCSPを適切に設定し、XSS攻撃やデータインジェクション攻撃から保護する方法を解説します。

CSPの基本

CSPディレクティブの種類

ディレクティブ説明
default-srcフォールバックポリシー
script-srcJavaScriptの読み込み元
style-srcCSSの読み込み元
img-src画像の読み込み元
font-srcフォントの読み込み元
connect-srcfetch/XHR/WebSocketの接続先
frame-srciframe/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モードでテスト後に本番適用
  • 最小権限の原則: 必要最小限のソースのみを許可
  • 環境別設定: 開発・本番で適切なポリシーを使い分け
  • モニタリング: 違反レポートを監視して問題を早期発見

参考文献

円