import { Env, TelegramUpdate } from './types'; import { createLogger } from './utils/logger'; const logger = createLogger('security'); // Telegram 서버 IP 대역 (2024년 기준) // https://core.telegram.org/bots/webhooks#the-short-version const TELEGRAM_IP_RANGES = [ '149.154.160.0/20', '91.108.4.0/22', ]; // CIDR 범위 체크 유틸 function ipInCIDR(ip: string, cidr: string): boolean { const [range, bits] = cidr.split('/'); const mask = ~(2 ** (32 - parseInt(bits)) - 1); const ipNum = ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet), 0); const rangeNum = range.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet), 0); return (ipNum & mask) === (rangeNum & mask); } // IP 화이트리스트 검증 function isValidTelegramIP(ip: string): boolean { return TELEGRAM_IP_RANGES.some(range => ipInCIDR(ip, range)); } /** * Timing-safe string comparison to prevent timing attacks * @param a - First string * @param b - Second string * @returns true if strings are equal */ export function timingSafeEqual(a: string | null | undefined, b: string | null | undefined): boolean { if (!a || !b) { return false; } if (a.length !== b.length) { return false; } let result = 0; for (let i = 0; i < a.length; i++) { result |= a.charCodeAt(i) ^ b.charCodeAt(i); } return result === 0; } // Webhook Secret Token 검증 (Timing-safe comparison) function isValidSecretToken(request: Request, expectedSecret: string): boolean { const secretHeader = request.headers.get('X-Telegram-Bot-Api-Secret-Token'); return timingSafeEqual(secretHeader, expectedSecret); } // 요청 본문 검증 function isValidRequestBody(body: unknown): body is TelegramUpdate { return ( body !== null && typeof body === 'object' && 'update_id' in body && typeof (body as TelegramUpdate).update_id === 'number' ); } // 타임스탬프 검증 (5분 이내 메시지만 허용 - 리플레이 공격 방지) function isRecentUpdate(message: TelegramUpdate['message']): boolean { // message가 없으면 callback_query 등일 수 있음 - 허용 if (!message?.date) return true; const messageTime = message.date * 1000; // Telegram uses Unix timestamp in seconds const now = Date.now(); const MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes return (now - messageTime) < MAX_AGE_MS; } export interface SecurityCheckResult { valid: boolean; error?: string; update?: TelegramUpdate; } // 통합 보안 검증 export async function validateWebhookRequest( request: Request, env: Env ): Promise { // 1. HTTP 메서드 검증 if (request.method !== 'POST') { return { valid: false, error: 'Method not allowed' }; } // 2. Content-Type 검증 const contentType = request.headers.get('Content-Type'); if (!contentType?.includes('application/json')) { return { valid: false, error: 'Invalid content type' }; } // 3. Secret Token 검증 (필수) if (!env.WEBHOOK_SECRET) { logger.error('WEBHOOK_SECRET not configured - rejecting request', new Error('Missing WEBHOOK_SECRET')); return { valid: false, error: 'Security configuration error' }; } if (!isValidSecretToken(request, env.WEBHOOK_SECRET)) { logger.warn('Invalid webhook secret token'); return { valid: false, error: 'Invalid secret token' }; } // 4. IP 화이트리스트 검증 (선택적 - CF에서는 CF-Connecting-IP 사용) const clientIP = request.headers.get('CF-Connecting-IP'); if (clientIP && !isValidTelegramIP(clientIP)) { // 경고만 로그 (Cloudflare 프록시 환경에서는 정확하지 않을 수 있음) logger.warn('Request from non-Telegram IP', { clientIP }); } // 5. 요청 본문 파싱 및 검증 let body: unknown; try { body = await request.json(); } catch { return { valid: false, error: 'Invalid JSON body' }; } if (!isValidRequestBody(body)) { return { valid: false, error: 'Invalid request body structure' }; } // 6. 타임스탬프 검증 (리플레이 공격 방지) if (!isRecentUpdate(body.message)) { return { valid: false, error: 'Message too old' }; } return { valid: true, update: body }; } // Rate Limiting (Cloudflare KV 기반) // Migrated to use KVCache abstraction layer (kv-cache.ts) import { createRateLimitCache, checkRateLimitWithCache } from './services/kv-cache'; export async function checkRateLimit( kv: KVNamespace, userId: string, maxRequests: number = 30, windowMs: number = 60000 ): Promise { // Convert windowMs (milliseconds) to windowSeconds (seconds) const windowSeconds = Math.ceil(windowMs / 1000); // Create KVCache instance with 'rate:' prefix const cache = createRateLimitCache(kv); // Delegate to unified KV cache implementation return checkRateLimitWithCache(cache, userId, maxRequests, windowSeconds); }