import { Env, TelegramUpdate } from './types'; // KV 오류 시 인메모리 폴백 (Worker 인스턴스 내) const fallbackRateLimits = new Map(); // 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' ); } // 타임스탬프 검증 (비활성화 - WEBHOOK_SECRET으로 충분) function isRecentUpdate(_message: TelegramUpdate['message']): boolean { return true; } 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) { console.error('WEBHOOK_SECRET not configured - rejecting request'); return { valid: false, error: 'Security configuration error' }; } if (!isValidSecretToken(request, env.WEBHOOK_SECRET)) { console.error('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 프록시 환경에서는 정확하지 않을 수 있음) console.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 기반) interface RateLimitData { count: number; resetAt: number; } export async function checkRateLimit( kv: KVNamespace, userId: string, maxRequests: number = 30, windowMs: number = 60000 ): Promise { const key = `ratelimit:${userId}`; const now = Date.now(); try { // KV에서 기존 데이터 조회 const dataStr = await kv.get(key); const data: RateLimitData | null = dataStr ? JSON.parse(dataStr) : null; // 윈도우 만료 또는 첫 요청 if (!data || now > data.resetAt) { const newData: RateLimitData = { count: 1, resetAt: now + windowMs, }; await kv.put(key, JSON.stringify(newData), { expirationTtl: Math.ceil(windowMs / 1000), // 초 단위 }); return true; } // Rate limit 초과 if (data.count >= maxRequests) { return false; } // 카운트 증가 const updatedData: RateLimitData = { count: data.count + 1, resetAt: data.resetAt, }; const remainingTtl = Math.ceil((data.resetAt - now) / 1000); await kv.put(key, JSON.stringify(updatedData), { expirationTtl: Math.max(remainingTtl, 1), // 최소 1초 }); return true; } catch (error) { console.error('[RateLimit] KV 오류:', error); // 인메모리 폴백으로 기본 보호 const fallbackKey = `fallback:${userId}`; const existing = fallbackRateLimits.get(fallbackKey); // 윈도우 만료 시 리셋 if (!existing || existing.resetAt < now) { fallbackRateLimits.set(fallbackKey, { count: 1, resetAt: now + 60000 }); // 1분 윈도우 return true; } // 제한 초과 체크 (인메모리에서는 더 보수적으로 10회) if (existing.count >= 10) { console.warn('[RateLimit] Fallback limit exceeded', { userId }); return false; } existing.count++; return true; } }