Documentation Next.js

はじめに

Cross-Site Request Forgery(CSRF)は、ユーザーが意図しないリクエストを送信させる攻撃です。この記事では、Next.js App RouterでCSRF対策を実装する方法を解説します。

CSRFの仕組みと対策

CSRF攻撃の流れ

1. ユーザーがサイトAにログイン(セッションCookie取得)
2. ユーザーが悪意あるサイトBを訪問
3. サイトBがユーザーのブラウザを経由してサイトAにリクエスト送信
4. ブラウザはセッションCookieを自動送信
5. サイトAはリクエストを正当なものとして処理

対策方法

方法説明
CSRFトークンリクエストに一意のトークンを含める
SameSite CookieCookieの送信を同一サイトに制限
Origin/Refererチェックリクエスト元を検証
Server ActionsNext.js組み込みのCSRF保護

CSRFトークンの実装

トークン生成・検証ユーティリティ

// lib/csrf.ts
import { cookies } from 'next/headers';
import crypto from 'crypto';

const CSRF_SECRET = process.env.CSRF_SECRET!;
const CSRF_COOKIE_NAME = '__Host-csrf-token';
const CSRF_HEADER_NAME = 'x-csrf-token';
const TOKEN_EXPIRY = 60 * 60 * 1000; // 1時間

interface CSRFToken {
  value: string;
  timestamp: number;
}

// トークン生成
export function generateCSRFToken(): string {
  const timestamp = Date.now();
  const randomValue = crypto.randomBytes(32).toString('hex');
  const payload = `${randomValue}.${timestamp}`;

  const signature = crypto
    .createHmac('sha256', CSRF_SECRET)
    .update(payload)
    .digest('hex');

  return `${payload}.${signature}`;
}

// トークン検証
export function validateCSRFToken(token: string): boolean {
  try {
    const parts = token.split('.');
    if (parts.length !== 3) return false;

    const [randomValue, timestampStr, signature] = parts;
    const timestamp = parseInt(timestampStr, 10);

    // 有効期限チェック
    if (Date.now() - timestamp > TOKEN_EXPIRY) {
      return false;
    }

    // 署名検証
    const payload = `${randomValue}.${timestampStr}`;
    const expectedSignature = crypto
      .createHmac('sha256', CSRF_SECRET)
      .update(payload)
      .digest('hex');

    return crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expectedSignature)
    );
  } catch {
    return false;
  }
}

// Cookieにトークンを設定
export function setCSRFCookie(token: string) {
  const cookieStore = cookies();

  cookieStore.set(CSRF_COOKIE_NAME, token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    path: '/',
    maxAge: 60 * 60, // 1時間
  });
}

// Cookieからトークンを取得
export function getCSRFTokenFromCookie(): string | undefined {
  const cookieStore = cookies();
  return cookieStore.get(CSRF_COOKIE_NAME)?.value;
}

// リクエストヘッダーからトークンを取得
export function getCSRFTokenFromHeader(
  headers: Headers
): string | null {
  return headers.get(CSRF_HEADER_NAME);
}

// CSRF検証
export function verifyCSRF(headers: Headers): boolean {
  const cookieToken = getCSRFTokenFromCookie();
  const headerToken = getCSRFTokenFromHeader(headers);

  if (!cookieToken || !headerToken) {
    return false;
  }

  // Cookie と Header のトークンが一致するか確認
  if (cookieToken !== headerToken) {
    return false;
  }

  // トークンの署名を検証
  return validateCSRFToken(cookieToken);
}

CSRFトークン取得API

// app/api/csrf/route.ts
import { NextResponse } from 'next/server';
import { generateCSRFToken, setCSRFCookie } from '@/lib/csrf';

export async function GET() {
  const token = generateCSRFToken();

  // Cookieにトークンを設定
  setCSRFCookie(token);

  // レスポンスでもトークンを返す(クライアントがヘッダーに設定するため)
  return NextResponse.json({ csrfToken: token });
}

保護されたAPIルート

// app/api/protected/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifyCSRF } from '@/lib/csrf';

export async function POST(request: NextRequest) {
  // CSRF検証
  if (!verifyCSRF(request.headers)) {
    return NextResponse.json(
      { error: 'Invalid CSRF token' },
      { status: 403 }
    );
  }

  // ビジネスロジック
  const body = await request.json();

  return NextResponse.json({
    success: true,
    message: 'Protected action completed',
    data: body,
  });
}

export async function DELETE(request: NextRequest) {
  // CSRF検証
  if (!verifyCSRF(request.headers)) {
    return NextResponse.json(
      { error: 'Invalid CSRF token' },
      { status: 403 }
    );
  }

  // 削除処理
  return NextResponse.json({ success: true });
}

Middlewareでの一括保護

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

const CSRF_HEADER_NAME = 'x-csrf-token';
const CSRF_COOKIE_NAME = '__Host-csrf-token';
const PROTECTED_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'];

export function middleware(request: NextRequest) {
  // GETリクエストはスキップ
  if (!PROTECTED_METHODS.includes(request.method)) {
    return NextResponse.next();
  }

  // APIルートのみ保護
  if (!request.nextUrl.pathname.startsWith('/api')) {
    return NextResponse.next();
  }

  // CSRFトークン取得APIはスキップ
  if (request.nextUrl.pathname === '/api/csrf') {
    return NextResponse.next();
  }

  const cookieToken = request.cookies.get(CSRF_COOKIE_NAME)?.value;
  const headerToken = request.headers.get(CSRF_HEADER_NAME);

  if (!cookieToken || !headerToken || cookieToken !== headerToken) {
    return NextResponse.json(
      { error: 'CSRF token validation failed' },
      { status: 403 }
    );
  }

  return NextResponse.next();
}

export const config = {
  matcher: '/api/:path*',
};

クライアントサイドの実装

CSRFプロバイダー

// contexts/CSRFContext.tsx
'use client';

import {
  createContext,
  useContext,
  useEffect,
  useState,
  useCallback,
} from 'react';

interface CSRFContextType {
  csrfToken: string | null;
  refreshToken: () => Promise<void>;
}

const CSRFContext = createContext<CSRFContextType>({
  csrfToken: null,
  refreshToken: async () => {},
});

export function CSRFProvider({ children }: { children: React.ReactNode }) {
  const [csrfToken, setCSRFToken] = useState<string | null>(null);

  const refreshToken = useCallback(async () => {
    try {
      const response = await fetch('/api/csrf');
      const data = await response.json();
      setCSRFToken(data.csrfToken);
    } catch (error) {
      console.error('Failed to fetch CSRF token:', error);
    }
  }, []);

  useEffect(() => {
    refreshToken();

    // 50分ごとにトークンを更新(有効期限1時間の前に)
    const interval = setInterval(refreshToken, 50 * 60 * 1000);

    return () => clearInterval(interval);
  }, [refreshToken]);

  return (
    <CSRFContext.Provider value={{ csrfToken, refreshToken }}>
      {children}
    </CSRFContext.Provider>
  );
}

export function useCSRF() {
  const context = useContext(CSRFContext);
  if (!context) {
    throw new Error('useCSRF must be used within CSRFProvider');
  }
  return context;
}

CSRF対応フェッチ関数

// lib/fetch.ts
'use client';

import { useCSRF } from '@/contexts/CSRFContext';

export function useSecureFetch() {
  const { csrfToken, refreshToken } = useCSRF();

  const secureFetch = async (
    url: string,
    options: RequestInit = {}
  ): Promise<Response> => {
    const headers = new Headers(options.headers);

    if (csrfToken) {
      headers.set('x-csrf-token', csrfToken);
    }

    const response = await fetch(url, {
      ...options,
      headers,
      credentials: 'include', // Cookieを送信
    });

    // 403の場合はトークンを更新してリトライ
    if (response.status === 403) {
      await refreshToken();
      return secureFetch(url, options);
    }

    return response;
  };

  return { secureFetch };
}

フォームコンポーネント

// components/SecureForm.tsx
'use client';

import { useState } from 'react';
import { useSecureFetch } from '@/lib/fetch';

export function SecureForm() {
  const [message, setMessage] = useState('');
  const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
  const { secureFetch } = useSecureFetch();

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    setStatus('loading');

    try {
      const response = await secureFetch('/api/protected', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ message }),
      });

      if (!response.ok) {
        throw new Error('Request failed');
      }

      const data = await response.json();
      console.log('Success:', data);
      setStatus('success');
      setMessage('');
    } catch (error) {
      console.error('Error:', error);
      setStatus('error');
    }
  };

  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <div>
        <label htmlFor="message" className="block text-sm font-medium">
          メッセージ
        </label>
        <input
          id="message"
          type="text"
          value={message}
          onChange={(e) => setMessage(e.target.value)}
          className="mt-1 block w-full rounded-md border p-2"
          required
        />
      </div>

      <button
        type="submit"
        disabled={status === 'loading'}
        className="rounded-md bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
      >
        {status === 'loading' ? '送信中...' : '送信'}
      </button>

      {status === 'success' && (
        <p className="text-green-600">送信に成功しました</p>
      )}
      {status === 'error' && (
        <p className="text-red-600">送信に失敗しました</p>
      )}
    </form>
  );
}

Server Actions(推奨)

Server Actionsは自動的にCSRF保護が組み込まれています。

// app/actions.ts
'use server';

import { revalidatePath } from 'next/cache';

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;

  // バリデーション
  if (!title || !content) {
    return { error: 'Title and content are required' };
  }

  // データベースに保存
  await db.post.create({
    data: { title, content },
  });

  revalidatePath('/posts');
  return { success: true };
}

export async function deletePost(id: string) {
  await db.post.delete({
    where: { id },
  });

  revalidatePath('/posts');
  return { success: true };
}
// components/PostForm.tsx
'use client';

import { useFormState, useFormStatus } from 'react-dom';
import { createPost } from '@/app/actions';

function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    <button
      type="submit"
      disabled={pending}
      className="rounded-md bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
    >
      {pending ? '投稿中...' : '投稿'}
    </button>
  );
}

export function PostForm() {
  const [state, formAction] = useFormState(createPost, null);

  return (
    <form action={formAction} className="space-y-4">
      <div>
        <label htmlFor="title">タイトル</label>
        <input
          id="title"
          name="title"
          type="text"
          required
          className="mt-1 block w-full rounded-md border p-2"
        />
      </div>

      <div>
        <label htmlFor="content">内容</label>
        <textarea
          id="content"
          name="content"
          required
          className="mt-1 block w-full rounded-md border p-2"
        />
      </div>

      <SubmitButton />

      {state?.error && (
        <p className="text-red-600">{state.error}</p>
      )}
    </form>
  );
}

SameSite Cookieとの併用

// lib/session.ts
import { cookies } from 'next/headers';

export function setSessionCookie(sessionId: string) {
  const cookieStore = cookies();

  cookieStore.set('session', sessionId, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict', // CSRF対策として重要
    path: '/',
    maxAge: 60 * 60 * 24 * 7, // 1週間
  });
}

まとめ

CSRF対策のポイントをまとめます。

対策推奨度説明
Server Actions★★★自動CSRF保護、最も簡単
CSRFトークン★★☆手動実装が必要だが柔軟
SameSite Cookie★★★必須、他の対策と併用

参考文献

円