はじめに
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 Cookie | Cookieの送信を同一サイトに制限 |
| Origin/Refererチェック | リクエスト元を検証 |
| Server Actions | Next.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 | ★★★ | 必須、他の対策と併用 |