Files
cloud-server/src/middleware/rateLimit.ts
kappa 1e750a863b refactor: 추천 시스템 제거
삭제된 파일:
- 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>
2026-01-26 00:35:59 +09:00

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;
}
}