Files
telegram-bot-workers/src/security.ts
kappa f304c6a7d4 refactor: apply new utilities and constants across codebase
P0 fixes:
- KV Cache migration: security.ts now delegates to kv-cache.ts (74% code reduction)
- Environment validation: index.ts validates env on first request
- Type safety: optimistic-lock.ts removes `as any` with proper interface

P1 improvements:
- Constants applied to deposit-agent.ts (TRANSACTION_STATUS, TRANSACTION_TYPE)
- Constants applied to callback-handler.ts (CALLBACK_PREFIXES)
- Constants applied to domain-tool.ts (MESSAGE_MARKERS)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 10:49:31 +09:00

159 lines
4.8 KiB
TypeScript

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<SecurityCheckResult> {
// 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<boolean> {
// 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);
}