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>
159 lines
4.8 KiB
TypeScript
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);
|
|
}
|