rate-limiting について
Next.js の API ルート向けのレート制限セキュリティスキルです。IP ごとのリクエストをメモリ内で追跡する再利用可能なミドルウェアで、総当たり攻撃やスパム、高コスト処理の不正利用を抑えます。
主な機能
- 01IP ごとに毎分 5 リクエストまで
- 02メモリ内でリクエストを追跡
- 03転送ヘッダー経由の IP 識別
- 04上限超過時に HTTP 429 を返却
- 05テスト用エンドポイントと検証スクリプト
活用シーン
- 01ログインやパスワードリセットの保護
- 02フォームやコメント投稿の頻度制限
- 03高コストな AI API 処理の制限
- 04上限や超過時の応答の確認
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ harperaa/ secure-claude-skills --skill rate-limitingインストール後、Claude Code で/rate-limitingを入力して読み込まれたか確認します。
詳細
レート制限 - ブルートフォース攻撃とリソースの悪用を防ぐ
レート制限が重要な理由
ブルートフォース攻撃の問題
レート制限がない場合、攻撃者は毎秒数千ものパスワードを試すことができます。6文字のパスワードには、3億800万通りの組み合わせがあります。
レート制限がない場合:
- 毎秒1,000回の試行 → 5分で解読
レート制限(5リクエスト/分)がある場合:
- 1分あたり5回の試行 → 117年かかります
実際に発生したブルートフォース攻撃
Zoomのクレデンシャルスタッフィング(2020年): 攻撃者は、盗んだ認証情報を使って50万回を超えるログイン試行を行いました。適切なレート制限があれば、最初の数百回の試行以内に検出してブロックできたはずです。
WordPressへの分散型攻撃(2021年): 複数のWordPressサイトが、数百万通りのログインの組み合わせを試す分散型ブルートフォース攻撃の標的になりました。レート制限がないサイトでは、攻撃者がリソースを消費するにつれてサーバーコストが急増しました。
リソースの悪用によるコスト
レート制限はセキュリティを守るだけでなく、インフラコストの抑制にも役立ちます。レート制限がない場合:
- ボットが問い合わせフォームに何千回もスパムを送信できます
- 攻撃者がコストの高い処理(AI API 呼び出し、データベースクエリ)を悪用できます
- 気づく前にサーバー料金が急増します
実話: あるスタートアップは、レート制限なしで「任意の記事を要約」するAI機能を開発しました。悪意のあるユーザーが数分で10,000件のリクエストをスクリプトで送信しました。AI API の料金により、10分間で9,600ドルの請求が発生しました。攻撃は気づかれないまま4時間続き、合計コストは20万ドル以上に達しました。
レート制限のアーキテクチャ
実装の機能
- IPアドレスごとに1分あたり5リクエスト - 使いやすさとセキュリティのバランスを取ります
- メモリ内で追跡 - 高速で、データベースの負荷がありません
- IPベースの識別 - x-forwarded-for を介してプロキシの背後でも機能します
- HTTP 429 ステータス - 標準の「リクエストが多すぎます」という応答です
- 共有枠 - withRateLimit() を使用するすべてのルートで、IPごとに共通の1分あたり5リクエストの制限を共有します
1分あたり5リクエストである理由
使いやすさとセキュリティに関する調査によると、正当なユーザーが同じエンドポイントに1分あたり5回を超えるリクエストを行うことはほとんどありません。この制限により:
- ✅ 自動化された攻撃を阻止します
- ✅ 実際のユーザーに影響しません
- ✅ フォームの妥当な再送信を許可します
- ✅ エラーからの復旧を試みることができます
IPごとに追跡する理由
- 個々のユーザーに個別の制限が適用されます
- あるIPへの攻撃が他のユーザーをブロックすることはありません
- 分散型攻撃では、ボットのIPごとに個別に制限されます
- 大規模な攻撃を効果のないものにします
実装ファイル
lib/withRateLimit.ts- レート制限ミドルウェアapp/api/test-rate-limit/route.ts- テスト用エンドポイントscripts/test-rate-limit.js- 検証スクリプト
レート制限の使い方
基本的な使い方
悪用される可能性のあるエンドポイントに適用します:
import { NextRequest, NextResponse } from 'next/server';
import { withRateLimit } from '@/lib/withRateLimit';
async function handler(request: NextRequest) {
// Your business logic
return NextResponse.json({ success: true });
}
// Apply rate limiting
export const POST = withRateLimit(handler);
export const config = {
runtime: 'nodejs',
};
CSRF 保護との併用
状態を変更する操作に最大限のセキュリティを適用する場合:
import { NextRequest, NextResponse } from 'next/server';
import { withRateLimit } from '@/lib/withRateLimit';
import { withCsrf } from '@/lib/withCsrf';
async function handler(request: NextRequest) {
// Business logic
return NextResponse.json({ success: true });
}
// Layer both protections (rate limit first, then CSRF)
export const POST = withRateLimit(withCsrf(handler));
export const config = {
runtime: 'nodejs',
};
レート制限を適用する場面
✅ 必ず適用する対象:
- 問い合わせ/サポートフォーム
- ニュースレターの登録
- アカウントの作成
- パスワードリセットのリクエスト
- ファイルアップロードのエンドポイント
- 検索エンドポイント
- データエクスポートのエンドポイント
- コストの高いAI/API処理
- Webhook エンドポイント
- コメント/レビューの投稿
- レポートの生成
- 一括操作
❌ 通常は不要な対象:
- 静的アセットへのリクエスト(CDN で処理)
- 公開データの読み取りのみを行う単純な GET エンドポイント
- ヘルスチェックのエンドポイント
- 認証のレート制限によってすでに保護されているエンドポイント
完全な例
例1:完全な保護を適用した問い合わせフォーム
// app/api/contact/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { withRateLimit } from '@/lib/withRateLimit';
import { withCsrf } from '@/lib/withCsrf';
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();
const validation = validateRequest(contactFormSchema, body);
if (!validation.success) {
return validation.response;
}
const { name, email, subject, message } = validation.data;
await sendEmail({
to: '[email protected]',
from: email,
subject,
message
});
return NextResponse.json({ success: true });
} catch (error) {
return handleApiError(error, 'contact-form');
}
}
export const POST = withRateLimit(withCsrf(contactHandler));
export const config = {
runtime: 'nodejs',
};
例2:AI API エンドポイント(コスト保護)
// app/api/summarize/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { withRateLimit } from '@/lib/withRateLimit';
import { auth } from '@clerk/nextjs/server';
import { handleApiError, handleUnauthorizedError } from '@/lib/errorHandler';
import OpenAI from 'openai';
const openai = new OpenAI();
async function summarizeHandler(request: NextRequest) {
try {
// Require authentication for expensive operations
const { userId } = await auth();
if (!userId) return handleUnauthorizedError();
const { text } = await request.json();
// Rate limiting prevents abuse of expensive AI API
const response = await openai.chat.completions.create({
model: 'gpt-4',
messages: [
{ role: 'system', content: 'Summarize the following text concisely.' },
{ role: 'user', content: text }
],
max_tokens: 150
});
return NextResponse.json({
summary: response.choices[0].message.content
});
} catch (error) {
return handleApiError(error, 'summarize');
}
}
// Protect expensive AI operations with rate limiting
export const POST = withRateLimit(summarizeHandler);
export const config = {
runtime: 'nodejs',
};
例3:ファイルアップロードのエンドポイント
// app/api/upload/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { withRateLimit } from '@/lib/withRateLimit';
import { auth } from '@clerk/nextjs/server';
import { handleApiError, handleUnauthorizedError } from '@/lib/errorHandler';
async function uploadHandler(request: NextRequest) {
try {
const { userId } = await auth();
if (!userId) return handleUnauthorizedError();
const formData = await request.formData();
const file = formData.get('file') as File;
if (!file) {
return NextResponse.json(
{ error: 'No file provided' },
{ status: 400 }
);
}
// Validate file size (10MB max)
if (file.size > 10 * 1024 * 1024) {
return NextResponse.json(
{ error: 'File too large (max 10MB)' },
{ status: 400 }
);
}
// Process upload
const uploadResult = await processFileUpload(file, userId);
return NextResponse.json({ success: true, fileId: uploadResult.id });
} catch (error) {
return handleApiError(error, 'upload');
}
}
// Prevent upload spam
export const POST = withRateLimit(uploadHandler);
export const config = {
runtime: 'nodejs',
};
技術的な実装の詳細
レートリミッターのコード(lib/withRateLimit.ts)
import { NextRequest, NextResponse } from 'next/server';
// In-memory storage for rate limiting
const rateLimitStore = new Map<string, { count: number; resetAt: number }>();
const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute in milliseconds
const MAX_REQUESTS = 5;
function getClientIp(request: NextRequest): string {
// Check for forwarded IP (when behind proxy/load balancer)
const forwarded = request.headers.get('x-forwarded-for');
if (forwarded) {
return forwarded.split(',')[0].trim();
}
const realIp = request.headers.get('x-real-ip');
if (realIp) {
return realIp;
}
// Fallback to direct IP
return request.ip || 'unknown';
}
function cleanupExpiredEntries() {
const now = Date.now();
for (const [key, value] of rateLimitStore.entries()) {
if (now > value.resetAt) {
rateLimitStore.delete(key);
}
}
}
export function withRateLimit(
handler: (request: NextRequest) => Promise<NextResponse>
) {
return async (request: NextRequest) => {
// Clean up expired entries periodically
cleanupExpiredEntries();
const clientIp = getClientIp(request);
const now = Date.now();
// Get or create rate limit entry
let rateLimitEntry = rateLimitStore.get(clientIp);
if (!rateLimitEntry || now > rateLimitEntry.resetAt) {
// Create new entry or reset expired one
rateLimitEntry = {
count: 0,
resetAt: now + RATE_LIMIT_WINDOW
};
rateLimitStore.set(clientIp, rateLimitEntry);
}
// Increment request count
rateLimitEntry.count++;
// Check if limit exceeded
if (rateLimitEntry.count > MAX_REQUESTS) {
const retryAfter = Math.ceil((rateLimitEntry.resetAt - now) / 1000);
return NextResponse.json(
{
error: 'Too many requests',
message: `Rate limit exceeded. Please try again in ${retryAfter} seconds.`,
retryAfter
},
{
status: 429,
headers: {
'Retry-After': retryAfter.toString(),
'X-RateLimit-Limit': MAX_REQUESTS.toString(),
'X-RateLimit-Remaining': '0',
'X-RateLimit-Reset': rateLimitEntry.resetAt.toString()
}
}
);
}
// Add rate limit headers to response
const response = await handler(request);
response.headers.set('X-RateLimit-Limit', MAX_REQUESTS.toString());
response.headers.set(
'X-RateLimit-Remaining',
(MAX_REQUESTS - rateLimitEntry.count).toString()
);
response.headers.set('X-RateLimit-Reset', rateLimitEntry.resetAt.toString());
return response;
};
}
クライアント側の処理
機能を維持しながらの縮退
async function submitForm(data: FormData) {
try {
const response = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (response.status === 429) {
// Rate limited
const result = await response.json();
alert(`Too many requests. Please wait ${result.retryAfter} seconds.`);
return;
}
if (response.ok) {
alert('Form submitted successfully!');
} else {
alert('Submission failed. Please try again.');
}
} catch (error) {
console.error('Error:', error);
alert('An error occurred. Please try again.');
}
}
指数バックオフによる自動再試行
async function submitWithRetry(
data: FormData,
maxRetries = 3
): Promise<Response> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const response = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (response.status !== 429) {
return response; // Success or non-rate-limit error
}
// Get retry-after header
const retryAfter = parseInt(response.headers.get('Retry-After') || '60');
if (attempt < maxRetries - 1) {
// Wait with exponential backoff
const delay = Math.min(retryAfter * 1000 * (2 ** attempt), 60000);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw new Error('Max retries exceeded');
}
レート制限の状態を表示する
'use client';
import { useState, useEffect } from 'react';
export function RateLimitStatus() {
const [limit, setLimit] = useState({ remaining: 5, total: 5 });
async function checkRateLimit() {
const response = await fetch('/api/test-rate-limit');
setLimit({
remaining: parseInt(response.headers.get('X-RateLimit-Remaining') || '5'),
total: parseInt(response.headers.get('X-RateLimit-Limit') || '5')
});
}
return (
<div>
<p>Requests remaining: {limit.remaining}/{limit.total}</p>
<button onClick={checkRateLimit}>Check Status</button>
</div>
);
}
攻撃シナリオと保護
攻撃 1:ブルートフォースログイン
攻撃:
# Attacker tries multiple passwords
for i in {1..1000}; do
curl -X POST https://yourapp.com/api/login \
-d "username=victim&password=attempt$i"
done
保護:
- リクエスト 1~5:通常どおり処理
- リクエスト 6 以降:HTTP 429 でブロック
- 5 回の試行後に攻撃を停止
- 攻撃者は、各バッチの間に 60 秒待つ必要があります
攻撃 2:フォームスパム
攻撃:
// Bot spams contact form
for (let i = 0; i < 1000; i++) {
fetch('/api/contact', {
method: 'POST',
body: JSON.stringify({ message: 'spam' })
});
}
保護:
- 最初の 5 件のリクエスト:受け付け
- それ以降のリクエスト:HTTP 429
- メールの大量送信を防止
- データベースへのスパム登録を防止
攻撃 3:リソース枯渇(AI API)
攻撃:
# Attacker abuses expensive AI endpoint
while true; do
curl -X POST https://yourapp.com/api/summarize \
-d '{"text": "very long text..."}'
done
保護:
- IP アドレスごとに、AI 呼び出しを 1 分あたり 5 回に制限
- 予期しない API 料金が数千ドルに達する事態を防止
- 正規のユーザーには影響しません
攻撃 4:分散攻撃
攻撃: 複数の IP アドレスから同時に攻撃
保護:
- IP アドレスごとに個別に追跡
- IP アドレスごとに 1 分あたり 5 件に制限
- 1 分あたり 5000 件のリクエストを送るには、攻撃に 1000 個の IP アドレスが必要
- 攻撃者にとって、より高コストで困難になります
レート制限のテスト
手動テスト
# Test rate limiting
for i in {1..10}; do
echo "Request $i:"
curl -s -o /dev/null -w "%{http_code}\n" \
http://localhost:3000/api/test-rate-limit
sleep 0.1
done
# Expected output:
# Request 1: 200
# Request 2: 200
# Request 3: 200
# Request 4: 200
# Request 5: 200
# Request 6: 429
# Request 7: 429
# Request 8: 429
# Request 9: 429
# Request 10: 429
自動テストスクリプト
# Run the provided test script
node scripts/test-rate-limit.js
# Expected output:
# Testing Rate Limiting (5 requests/minute per IP)
# Request 1: ✓ 200 - Success
# Request 2: ✓ 200 - Success
# Request 3: ✓ 200 - Success
# Request 4: ✓ 200 - Success
# Request 5: ✓ 200 - Success
# Request 6: ✗ 429 - Too many requests
# Request 7: ✗ 429 - Too many requests
# Request 8: ✗ 429 - Too many requests
# Request 9: ✗ 429 - Too many requests
# Request 10: ✗ 429 - Too many requests
# ✓ Rate limiting is working correctly!
ウィンドウ経過後のテストのリセット
# Make 5 requests
for i in {1..5}; do
curl http://localhost:3000/api/test-rate-limit
done
# Wait 61 seconds
sleep 61
# Try again - should succeed
curl http://localhost:3000/api/test-rate-limit
# Expected: 200 OK (limit reset)
応用:カスタムレート制限
エンドポイントごとに異なる制限が必要な場合:
// lib/withCustomRateLimit.ts
import { NextRequest, NextResponse } from 'next/server';
const rateLimitStore = new Map<string, { count: number; resetAt: number }>();
export function withCustomRateLimit(
maxRequests: number,
windowMs: number
) {
return (handler: (request: NextRequest) => Promise<NextResponse>) => {
return async (request: NextRequest) => {
const clientIp = getClientIp(request);
const now = Date.now();
const key = `${clientIp}:${request.url}`;
let entry = rateLimitStore.get(key);
if (!entry || now > entry.resetAt) {
entry = { count: 0, resetAt: now + windowMs };
rateLimitStore.set(key, entry);
}
entry.count++;
if (entry.count > maxRequests) {
return NextResponse.json(
{ error: 'Too many requests' },
{ status: 429 }
);
}
return handler(request);
};
};
}
// Usage:
// export const POST = withCustomRateLimit(10, 60000)(handler); // 10 req/min
// export const POST = withCustomRateLimit(100, 3600000)(handler); // 100 req/hour
本番環境での考慮事項
Redis ベースのレート制限(本番環境に推奨)
複数のサーバーを使用する本番環境へのデプロイでは、Redis を使用してください:
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
export function withRedisRateLimit(
handler: (request: NextRequest) => Promise<NextResponse>
) {
return async (request: NextRequest) => {
const clientIp = getClientIp(request);
const key = `rate-limit:${clientIp}`;
const current = await redis.incr(key);
if (current === 1) {
await redis.expire(key, 60); // 60 second window
}
if (current > 5) {
const ttl = await redis.ttl(key);
return NextResponse.json(
{ error: 'Too many requests', retryAfter: ttl },
{ status: 429 }
);
}
return handler(request);
};
}
レート制限違反のログ記録
import { logSecurityEvent } from '@/lib/security-logger';
// In withRateLimit function, when limit exceeded:
if (rateLimitEntry.count > MAX_REQUESTS) {
// Log potential attack
logSecurityEvent({
type: 'RATE_LIMIT_EXCEEDED',
ip: clientIp,
path: request.nextUrl.pathname,
count: rateLimitEntry.count,
timestamp: new Date().toISOString()
});
return NextResponse.json(/* ... */);
}
レート制限で防止できること
✅ パスワードへのブルートフォース攻撃 - 主な保護対象 ✅ クレデンシャルスタッフィング - 自動化されたログイン試行 ✅ API の不正利用とスパム - ボットによるスパムを防止 ✅ リソース枯渇(DoS) - サーバーへの過負荷を防止 ✅ AI/有料 API の過剰な利用によるコスト - コストを保護 ✅ スクレイピングとデータ収集 - 大量収集を遅らせる ✅ アカウントの列挙 - 有効なアカウントの特定を防止
避けるべきよくある間違い
❌ 高コストな操作にレート制限を適用しないのは避けてください ❌ 別々の制限を設けずに、異なる操作で同じエンドポイントを使用しないでください ❌ クライアント側のレート制限だけに頼らないでください ❌ フロントエンドで HTTP 429 のレスポンスを適切に処理するのを忘れないでください ❌ 制限を高く設定しすぎる(目的を損なう)ことも、低く設定しすぎる(UX を損なう)ことも避けてください
✅ 悪用される可能性があるすべての公開エンドポイントに適用してください ✅ 複数サーバー構成の本番環境では Redis を使用してください ✅ セキュリティ監視のため、レート制限違反をログに記録してください ✅ 再試行までの時間を明記した、わかりやすいエラーメッセージを表示してください ✅ 最大限の保護のため、CSRF 対策と組み合わせてください
参考資料
- OWASP API Security Top 10 - リソース消費の制限なし: https://owasp.org/API-Security/editions/2023/en/0xa4-unrestricted-resource-consumption/
- レート制限のベストプラクティス: https://cloud.google.com/architecture/rate-limiting-strategies-techniques
- HTTP 429 ステータスコード: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429
次のステップ
- CSRF 対策については、
csrf-protectionスキルを使用してください - 入力の検証については、
input-validationスキルを使用してください - レート制限のテストについては、
security-testingスキルを使用してください - API の完全なセキュリティ対策には、レート制限 + CSRF + 検証を組み合わせてください