삭제된 파일: - src/routes/recommend.ts - src/services/recommendation.ts - src/services/recommendation.test.ts - src/services/stackConfig.ts - src/services/regionFilter.ts 수정된 파일: - src/index.ts: /recommend 라우트 제거 - src/routes/index.ts: handleRecommend export 제거 - src/constants.ts: RECOMMENDATIONS TTL, rate limit 제거 - src/middleware/rateLimit.ts: /recommend 설정 제거 - src/types.ts: 추천 관련 타입 제거 - scripts/e2e-tester.ts: recommend 시나리오 제거 - scripts/api-tester.ts: recommend 테스트 제거 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
262 lines
7.7 KiB
TypeScript
262 lines
7.7 KiB
TypeScript
/**
|
|
* Rate Limiting Middleware - Cloudflare KV Based
|
|
*
|
|
* Distributed rate limiting using Cloudflare KV for multi-worker support.
|
|
* Different limits for different endpoints.
|
|
*
|
|
* CONCURRENCY CONTROL:
|
|
*
|
|
* Uses optimistic locking with versioned metadata for race condition safety.
|
|
* Each KV entry includes a version number that is incremented on every write.
|
|
* Accepts eventual consistency - no post-write verification needed for
|
|
* abuse prevention use case.
|
|
*
|
|
* KNOWN LIMITATIONS:
|
|
*
|
|
* 1. EVENTUAL CONSISTENCY: KV writes are eventually consistent, which may
|
|
* cause slight inaccuracies in rate counting across edge locations.
|
|
* This is acceptable for abuse prevention.
|
|
*
|
|
* 2. RACE CONDITIONS: Multiple concurrent requests may all succeed if they
|
|
* read before any writes complete. This is acceptable - rate limiting
|
|
* provides statistical protection, not strict guarantees.
|
|
*
|
|
* For strict rate limiting requirements (billing, quotas), consider:
|
|
* - Cloudflare Durable Objects for atomic counters with strong consistency
|
|
* - Cloudflare's built-in Rate Limiting rules for global enforcement
|
|
*
|
|
* This implementation is suitable for abuse prevention with single KV read
|
|
* per request (30-50ms overhead).
|
|
*/
|
|
|
|
import { Env } from '../types';
|
|
import { RATE_LIMIT_DEFAULTS, HTTP_STATUS } from '../constants';
|
|
import { createLogger } from '../utils/logger';
|
|
|
|
const logger = createLogger('[RateLimit]');
|
|
|
|
/**
|
|
* Rate limit configuration per endpoint
|
|
*/
|
|
interface RateLimitConfig {
|
|
/** Maximum requests allowed in the time window */
|
|
maxRequests: number;
|
|
/** Time window in milliseconds */
|
|
windowMs: number;
|
|
}
|
|
|
|
/**
|
|
* Rate limit tracking entry stored in KV
|
|
*/
|
|
interface RateLimitEntry {
|
|
/** Request count in current window */
|
|
count: number;
|
|
/** Window start timestamp */
|
|
windowStart: number;
|
|
}
|
|
|
|
/**
|
|
* Rate limit configurations by endpoint
|
|
*/
|
|
const RATE_LIMITS: Record<string, RateLimitConfig> = {
|
|
'/instances': {
|
|
maxRequests: RATE_LIMIT_DEFAULTS.MAX_REQUESTS_INSTANCES,
|
|
windowMs: RATE_LIMIT_DEFAULTS.WINDOW_MS,
|
|
},
|
|
'/sync': {
|
|
maxRequests: RATE_LIMIT_DEFAULTS.MAX_REQUESTS_SYNC,
|
|
windowMs: RATE_LIMIT_DEFAULTS.WINDOW_MS,
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Get client IP from request
|
|
*
|
|
* Security: Only trust CF-Connecting-IP header from Cloudflare.
|
|
* X-Forwarded-For can be spoofed by clients and should NOT be trusted.
|
|
*/
|
|
function getClientIP(request: Request): string {
|
|
// Only trust Cloudflare-provided IP header
|
|
const cfIP = request.headers.get('CF-Connecting-IP');
|
|
if (cfIP) return cfIP;
|
|
|
|
// For non-Cloudflare requests, fail-closed: use a fixed identifier
|
|
// This applies a shared rate limit to all non-Cloudflare traffic
|
|
logger.warn('CF-Connecting-IP missing - applying shared rate limit');
|
|
return 'non-cloudflare-traffic';
|
|
}
|
|
|
|
/**
|
|
* Check if request is rate limited using Cloudflare KV
|
|
*
|
|
* Simplified flow accepting eventual consistency:
|
|
* 1. Read entry with version from KV metadata
|
|
* 2. Check if limit exceeded
|
|
* 3. Increment counter and version
|
|
* 4. Write with new version
|
|
* 5. Return decision based on pre-increment count
|
|
*
|
|
* No post-write verification - accepts eventual consistency for rate limiting.
|
|
* Version metadata is still incremented for race condition safety.
|
|
*
|
|
* @param request - HTTP request to check
|
|
* @param path - Request path for rate limit lookup
|
|
* @param env - Cloudflare Worker environment with KV binding
|
|
* @returns Object with allowed status and optional retry-after seconds
|
|
*/
|
|
export async function checkRateLimit(
|
|
request: Request,
|
|
path: string,
|
|
env: Env
|
|
): Promise<{ allowed: boolean; retryAfter?: number }> {
|
|
const config = RATE_LIMITS[path];
|
|
if (!config) {
|
|
// No rate limit configured for this path
|
|
return { allowed: true };
|
|
}
|
|
|
|
try {
|
|
const clientIP = getClientIP(request);
|
|
const key = `ratelimit:${clientIP}:${path}`;
|
|
const now = Date.now();
|
|
|
|
// 1. Read current entry with version metadata (single read)
|
|
const kvResult = await env.RATE_LIMIT_KV.getWithMetadata<{ version?: number }>(key);
|
|
const currentVersion = kvResult.metadata?.version ?? 0;
|
|
let entry: RateLimitEntry | null = null;
|
|
|
|
if (kvResult.value) {
|
|
try {
|
|
entry = JSON.parse(kvResult.value);
|
|
} catch {
|
|
// Invalid JSON, treat as no entry
|
|
entry = null;
|
|
}
|
|
}
|
|
|
|
// 2. Check if window has expired or no entry exists
|
|
if (!entry || entry.windowStart + config.windowMs <= now) {
|
|
// New window - create entry with count 1
|
|
const newEntry: RateLimitEntry = {
|
|
count: 1,
|
|
windowStart: now,
|
|
};
|
|
|
|
const ttlSeconds = Math.ceil(config.windowMs / 1000);
|
|
|
|
// Write with version 0 (reset on new window) - no verification
|
|
await env.RATE_LIMIT_KV.put(key, JSON.stringify(newEntry), {
|
|
expirationTtl: ttlSeconds,
|
|
metadata: { version: 0 },
|
|
});
|
|
|
|
// Allow request - first in new window
|
|
return { allowed: true };
|
|
}
|
|
|
|
// 3. Check current count against limit
|
|
const currentCount = entry.count;
|
|
const isOverLimit = currentCount >= config.maxRequests;
|
|
|
|
// 4. Increment count and version for next request
|
|
const newVersion = currentVersion + 1;
|
|
const updatedEntry: RateLimitEntry = {
|
|
count: currentCount + 1,
|
|
windowStart: entry.windowStart,
|
|
};
|
|
|
|
const windowEnd = entry.windowStart + config.windowMs;
|
|
const ttlSeconds = Math.ceil((windowEnd - now) / 1000);
|
|
|
|
// 5. Write updated count (only if TTL valid) - no verification
|
|
if (ttlSeconds > 0) {
|
|
await env.RATE_LIMIT_KV.put(key, JSON.stringify(updatedEntry), {
|
|
expirationTtl: ttlSeconds,
|
|
metadata: { version: newVersion },
|
|
});
|
|
}
|
|
|
|
// 6. Return decision based on pre-increment count
|
|
if (isOverLimit) {
|
|
const retryAfter = Math.ceil((windowEnd - now) / 1000);
|
|
return { allowed: false, retryAfter };
|
|
}
|
|
|
|
return { allowed: true };
|
|
} catch (error) {
|
|
// Fail-closed on KV errors for security
|
|
logger.error('KV error, blocking request for safety', { error });
|
|
return { allowed: false, retryAfter: 60 };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create 429 Too Many Requests response
|
|
*/
|
|
export function createRateLimitResponse(retryAfter: number): Response {
|
|
return Response.json(
|
|
{
|
|
error: 'Too Many Requests',
|
|
message: 'Rate limit exceeded. Please try again later.',
|
|
retry_after_seconds: retryAfter,
|
|
timestamp: new Date().toISOString(),
|
|
},
|
|
{
|
|
status: HTTP_STATUS.TOO_MANY_REQUESTS,
|
|
headers: {
|
|
'Retry-After': retryAfter.toString(),
|
|
'X-RateLimit-Retry-After': retryAfter.toString(),
|
|
},
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get current rate limit status for a client
|
|
* (useful for debugging or adding X-RateLimit-* headers)
|
|
*/
|
|
export async function getRateLimitStatus(
|
|
request: Request,
|
|
path: string,
|
|
env: Env
|
|
): Promise<{ limit: number; remaining: number; resetAt: number } | null> {
|
|
const config = RATE_LIMITS[path];
|
|
if (!config) return null;
|
|
|
|
try {
|
|
const clientIP = getClientIP(request);
|
|
const now = Date.now();
|
|
const key = `ratelimit:${clientIP}:${path}`;
|
|
|
|
const entryJson = await env.RATE_LIMIT_KV.get(key);
|
|
|
|
if (!entryJson) {
|
|
return {
|
|
limit: config.maxRequests,
|
|
remaining: config.maxRequests,
|
|
resetAt: now + config.windowMs,
|
|
};
|
|
}
|
|
|
|
const entry: RateLimitEntry = JSON.parse(entryJson);
|
|
|
|
// Check if window expired
|
|
if (entry.windowStart + config.windowMs <= now) {
|
|
return {
|
|
limit: config.maxRequests,
|
|
remaining: config.maxRequests,
|
|
resetAt: now + config.windowMs,
|
|
};
|
|
}
|
|
|
|
return {
|
|
limit: config.maxRequests,
|
|
remaining: Math.max(0, config.maxRequests - entry.count),
|
|
resetAt: entry.windowStart + config.windowMs,
|
|
};
|
|
} catch (error) {
|
|
logger.error('Status check error', { error });
|
|
return null;
|
|
}
|
|
}
|