diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..47924f2 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# Telegram Bot Workers - Environment Variables Example +# Copy this file to .env and fill in your values +# NEVER commit .env with real values! + +# Webhook secret for CLI testing (npm run chat) +# Generate with: openssl rand -hex 16 +WEBHOOK_SECRET=your_webhook_secret_here diff --git a/.gitignore b/.gitignore index dcf4211..b296102 100644 --- a/.gitignore +++ b/.gitignore @@ -8,8 +8,11 @@ dist/ # Environment & Secrets .env -.env.* +.env.local +.env.*.local .dev.vars +# Keep .env.example for documentation +!.env.example # IDE .idea/ diff --git a/src/routes/api.ts b/src/routes/api.ts index 2f17e53..09c7067 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -10,6 +10,7 @@ import { handleCommand } from '../commands'; import { openaiCircuitBreaker } from '../openai-service'; import { createLogger } from '../utils/logger'; import { toError } from '../utils/error'; +import { timingSafeEqual } from '../security'; const logger = createLogger('api'); @@ -34,13 +35,13 @@ const ContactFormBodySchema = z.object({ }); /** - * API Key 인증 검증 + * API Key 인증 검증 (Timing-safe comparison으로 타이밍 공격 방지) * @returns 인증 실패 시 Response, 성공 시 null */ function requireApiKey(request: Request, env: Env): Response | null { const apiSecret = env.DEPOSIT_API_SECRET; const authHeader = request.headers.get('X-API-Key'); - if (!apiSecret || authHeader !== apiSecret) { + if (!apiSecret || !timingSafeEqual(authHeader, apiSecret)) { return Response.json({ error: 'Unauthorized' }, { status: 401 }); } return null; diff --git a/src/security.ts b/src/security.ts index 2d3d857..a777985 100644 --- a/src/security.ts +++ b/src/security.ts @@ -23,27 +23,35 @@ function isValidTelegramIP(ip: string): boolean { return TELEGRAM_IP_RANGES.some(range => ipInCIDR(ip, range)); } -// Webhook Secret Token 검증 (Timing-safe comparison) -function isValidSecretToken(request: Request, expectedSecret: string): boolean { - const secretHeader = request.headers.get('X-Telegram-Bot-Api-Secret-Token'); - - if (!secretHeader || !expectedSecret) { +/** + * 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; } - // Timing-safe comparison - if (secretHeader.length !== expectedSecret.length) { + if (a.length !== b.length) { return false; } let result = 0; - for (let i = 0; i < secretHeader.length; i++) { - result |= secretHeader.charCodeAt(i) ^ expectedSecret.charCodeAt(i); + 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 ( diff --git a/src/services/deposit-matcher.ts b/src/services/deposit-matcher.ts index 3e87ea0..4bc315b 100644 --- a/src/services/deposit-matcher.ts +++ b/src/services/deposit-matcher.ts @@ -1,5 +1,6 @@ import { BankNotification } from '../types'; import { createLogger } from '../utils/logger'; +import { executeWithOptimisticLock, OptimisticLockError } from '../utils/optimistic-lock'; const logger = createLogger('deposit-matcher'); @@ -61,33 +62,51 @@ export async function matchPendingDeposit( console.log('[matchPendingDeposit] 매칭 발견:', pendingTx); try { - // 트랜잭션: 거래 확정 + 잔액 증가 + 알림 매칭 업데이트 - const results = await db.batch([ - db.prepare( - "UPDATE deposit_transactions SET status = 'confirmed', confirmed_at = CURRENT_TIMESTAMP WHERE id = ?" - ).bind(pendingTx.id), - db.prepare( - 'UPDATE user_deposits SET balance = balance + ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?' - ).bind(pendingTx.amount, pendingTx.user_id), - db.prepare( - 'UPDATE bank_notifications SET matched_transaction_id = ? WHERE id = ?' - ).bind(pendingTx.id, notificationId), - ]); + // Optimistic Locking으로 동시성 안전한 매칭 처리 + await executeWithOptimisticLock(db, async (attempt) => { + logger.info('SMS 자동 매칭 시도', { attempt, transactionId: pendingTx.id }); - // Batch 결과 검증 - const allSuccessful = results.every(r => r.success && r.meta?.changes && r.meta.changes > 0); - if (!allSuccessful) { - logger.error('Batch 부분 실패 (입금 자동 매칭 - SMS)', undefined, { - results, - userId: pendingTx.user_id, + // 1. 거래 상태 변경 (pending → confirmed) + const txUpdate = await db.prepare( + "UPDATE deposit_transactions SET status = 'confirmed', confirmed_at = CURRENT_TIMESTAMP WHERE id = ? AND status = 'pending'" + ).bind(pendingTx.id).run(); + + if (!txUpdate.success || txUpdate.meta.changes === 0) { + // 이미 다른 프로세스가 처리함 (중복 매칭 방지) + throw new Error('Transaction already processed or not found'); + } + + // 2. 현재 잔액/버전 조회 + const current = await db.prepare( + 'SELECT balance, version FROM user_deposits WHERE user_id = ?' + ).bind(pendingTx.user_id).first<{ balance: number; version: number }>(); + + if (!current) { + throw new Error('User deposit account not found'); + } + + // 3. 잔액 증가 (버전 체크로 동시성 보호) + const balanceUpdate = await db.prepare( + 'UPDATE user_deposits SET balance = balance + ?, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE user_id = ? AND version = ?' + ).bind(pendingTx.amount, pendingTx.user_id, current.version).run(); + + if (!balanceUpdate.success || balanceUpdate.meta.changes === 0) { + throw new OptimisticLockError('Version mismatch on balance update'); + } + + // 4. 알림과 거래 연결 + await db.prepare( + 'UPDATE bank_notifications SET matched_transaction_id = ? WHERE id = ?' + ).bind(pendingTx.id, notificationId).run(); + + logger.info('SMS 자동 매칭 완료', { + attempt, transactionId: pendingTx.id, + userId: pendingTx.user_id, amount: pendingTx.amount, - notificationId, - depositorName: notification.depositorName, - context: 'match_pending_deposit_sms' + newBalance: current.balance + pendingTx.amount, }); - throw new Error('거래 처리 실패 - 관리자에게 문의하세요'); - } + }); console.log('[matchPendingDeposit] 매칭 완료:', { transactionId: pendingTx.id, @@ -101,6 +120,12 @@ export async function matchPendingDeposit( amount: pendingTx.amount, }; } catch (error) { + if (error instanceof OptimisticLockError) { + logger.error('SMS 자동 매칭 동시성 충돌', error, { + transactionId: pendingTx.id, + userId: pendingTx.user_id, + }); + } console.error('[matchPendingDeposit] DB 업데이트 실패:', error); throw error; } diff --git a/wrangler.toml b/wrangler.toml index 38032f3..6d1dc20 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -9,8 +9,7 @@ binding = "AI" SUMMARY_THRESHOLD = "20" # 프로필 업데이트 주기 (메시지 수) MAX_SUMMARIES_PER_USER = "3" # 유지할 프로필 버전 수 (슬라이딩 윈도우) N8N_WEBHOOK_URL = "https://n8n.anvil.it.com" # n8n 연동 (선택) -DOMAIN_OWNER_ID = "821596605" # 도메인 관리 권한 Telegram ID -DEPOSIT_ADMIN_ID = "821596605" # 예치금 관리 권한 Telegram ID +# Admin IDs moved to secrets (see bottom of file) # API Endpoints OPENAI_API_BASE = "https://gateway.ai.cloudflare.com/v1/d8e5997eb4040f8b489f09095c0f623c/telegram-bot/openai" @@ -47,3 +46,5 @@ crons = ["0 15 * * *"] # UTC 15:00 = KST 00:00 # - NAMECHEAP_API_KEY_INTERNAL: Namecheap API 키 (내부용) # - BRAVE_API_KEY: Brave Search API 키 # - DEPOSIT_API_SECRET: Deposit API 인증 키 (namecheap-api 연동) +# - DOMAIN_OWNER_ID: 도메인 관리 권한 Telegram ID (보안상 secrets 권장) +# - DEPOSIT_ADMIN_ID: 예치금 관리 권한 Telegram ID (보안상 secrets 권장)