fix: critical security improvements
- Apply optimistic locking to deposit-matcher.ts (race condition fix) - Add timing-safe comparison for API key validation - Move admin IDs from wrangler.toml vars to secrets - Add .env.example for secure credential management Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
7
.env.example
Normal file
7
.env.example
Normal file
@@ -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
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -8,8 +8,11 @@ dist/
|
|||||||
|
|
||||||
# Environment & Secrets
|
# Environment & Secrets
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.local
|
||||||
|
.env.*.local
|
||||||
.dev.vars
|
.dev.vars
|
||||||
|
# Keep .env.example for documentation
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { handleCommand } from '../commands';
|
|||||||
import { openaiCircuitBreaker } from '../openai-service';
|
import { openaiCircuitBreaker } from '../openai-service';
|
||||||
import { createLogger } from '../utils/logger';
|
import { createLogger } from '../utils/logger';
|
||||||
import { toError } from '../utils/error';
|
import { toError } from '../utils/error';
|
||||||
|
import { timingSafeEqual } from '../security';
|
||||||
|
|
||||||
const logger = createLogger('api');
|
const logger = createLogger('api');
|
||||||
|
|
||||||
@@ -34,13 +35,13 @@ const ContactFormBodySchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API Key 인증 검증
|
* API Key 인증 검증 (Timing-safe comparison으로 타이밍 공격 방지)
|
||||||
* @returns 인증 실패 시 Response, 성공 시 null
|
* @returns 인증 실패 시 Response, 성공 시 null
|
||||||
*/
|
*/
|
||||||
function requireApiKey(request: Request, env: Env): Response | null {
|
function requireApiKey(request: Request, env: Env): Response | null {
|
||||||
const apiSecret = env.DEPOSIT_API_SECRET;
|
const apiSecret = env.DEPOSIT_API_SECRET;
|
||||||
const authHeader = request.headers.get('X-API-Key');
|
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 Response.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -23,27 +23,35 @@ function isValidTelegramIP(ip: string): boolean {
|
|||||||
return TELEGRAM_IP_RANGES.some(range => ipInCIDR(ip, range));
|
return TELEGRAM_IP_RANGES.some(range => ipInCIDR(ip, range));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Webhook Secret Token 검증 (Timing-safe comparison)
|
/**
|
||||||
function isValidSecretToken(request: Request, expectedSecret: string): boolean {
|
* Timing-safe string comparison to prevent timing attacks
|
||||||
const secretHeader = request.headers.get('X-Telegram-Bot-Api-Secret-Token');
|
* @param a - First string
|
||||||
|
* @param b - Second string
|
||||||
if (!secretHeader || !expectedSecret) {
|
* @returns true if strings are equal
|
||||||
|
*/
|
||||||
|
export function timingSafeEqual(a: string | null | undefined, b: string | null | undefined): boolean {
|
||||||
|
if (!a || !b) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Timing-safe comparison
|
if (a.length !== b.length) {
|
||||||
if (secretHeader.length !== expectedSecret.length) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = 0;
|
let result = 0;
|
||||||
for (let i = 0; i < secretHeader.length; i++) {
|
for (let i = 0; i < a.length; i++) {
|
||||||
result |= secretHeader.charCodeAt(i) ^ expectedSecret.charCodeAt(i);
|
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result === 0;
|
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 {
|
function isValidRequestBody(body: unknown): body is TelegramUpdate {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { BankNotification } from '../types';
|
import { BankNotification } from '../types';
|
||||||
import { createLogger } from '../utils/logger';
|
import { createLogger } from '../utils/logger';
|
||||||
|
import { executeWithOptimisticLock, OptimisticLockError } from '../utils/optimistic-lock';
|
||||||
|
|
||||||
const logger = createLogger('deposit-matcher');
|
const logger = createLogger('deposit-matcher');
|
||||||
|
|
||||||
@@ -61,33 +62,51 @@ export async function matchPendingDeposit(
|
|||||||
console.log('[matchPendingDeposit] 매칭 발견:', pendingTx);
|
console.log('[matchPendingDeposit] 매칭 발견:', pendingTx);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 트랜잭션: 거래 확정 + 잔액 증가 + 알림 매칭 업데이트
|
// Optimistic Locking으로 동시성 안전한 매칭 처리
|
||||||
const results = await db.batch([
|
await executeWithOptimisticLock(db, async (attempt) => {
|
||||||
db.prepare(
|
logger.info('SMS 자동 매칭 시도', { attempt, transactionId: pendingTx.id });
|
||||||
"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),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Batch 결과 검증
|
// 1. 거래 상태 변경 (pending → confirmed)
|
||||||
const allSuccessful = results.every(r => r.success && r.meta?.changes && r.meta.changes > 0);
|
const txUpdate = await db.prepare(
|
||||||
if (!allSuccessful) {
|
"UPDATE deposit_transactions SET status = 'confirmed', confirmed_at = CURRENT_TIMESTAMP WHERE id = ? AND status = 'pending'"
|
||||||
logger.error('Batch 부분 실패 (입금 자동 매칭 - SMS)', undefined, {
|
).bind(pendingTx.id).run();
|
||||||
results,
|
|
||||||
userId: pendingTx.user_id,
|
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,
|
transactionId: pendingTx.id,
|
||||||
|
userId: pendingTx.user_id,
|
||||||
amount: pendingTx.amount,
|
amount: pendingTx.amount,
|
||||||
notificationId,
|
newBalance: current.balance + pendingTx.amount,
|
||||||
depositorName: notification.depositorName,
|
|
||||||
context: 'match_pending_deposit_sms'
|
|
||||||
});
|
});
|
||||||
throw new Error('거래 처리 실패 - 관리자에게 문의하세요');
|
});
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[matchPendingDeposit] 매칭 완료:', {
|
console.log('[matchPendingDeposit] 매칭 완료:', {
|
||||||
transactionId: pendingTx.id,
|
transactionId: pendingTx.id,
|
||||||
@@ -101,6 +120,12 @@ export async function matchPendingDeposit(
|
|||||||
amount: pendingTx.amount,
|
amount: pendingTx.amount,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof OptimisticLockError) {
|
||||||
|
logger.error('SMS 자동 매칭 동시성 충돌', error, {
|
||||||
|
transactionId: pendingTx.id,
|
||||||
|
userId: pendingTx.user_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
console.error('[matchPendingDeposit] DB 업데이트 실패:', error);
|
console.error('[matchPendingDeposit] DB 업데이트 실패:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ binding = "AI"
|
|||||||
SUMMARY_THRESHOLD = "20" # 프로필 업데이트 주기 (메시지 수)
|
SUMMARY_THRESHOLD = "20" # 프로필 업데이트 주기 (메시지 수)
|
||||||
MAX_SUMMARIES_PER_USER = "3" # 유지할 프로필 버전 수 (슬라이딩 윈도우)
|
MAX_SUMMARIES_PER_USER = "3" # 유지할 프로필 버전 수 (슬라이딩 윈도우)
|
||||||
N8N_WEBHOOK_URL = "https://n8n.anvil.it.com" # n8n 연동 (선택)
|
N8N_WEBHOOK_URL = "https://n8n.anvil.it.com" # n8n 연동 (선택)
|
||||||
DOMAIN_OWNER_ID = "821596605" # 도메인 관리 권한 Telegram ID
|
# Admin IDs moved to secrets (see bottom of file)
|
||||||
DEPOSIT_ADMIN_ID = "821596605" # 예치금 관리 권한 Telegram ID
|
|
||||||
|
|
||||||
# API Endpoints
|
# API Endpoints
|
||||||
OPENAI_API_BASE = "https://gateway.ai.cloudflare.com/v1/d8e5997eb4040f8b489f09095c0f623c/telegram-bot/openai"
|
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 키 (내부용)
|
# - NAMECHEAP_API_KEY_INTERNAL: Namecheap API 키 (내부용)
|
||||||
# - BRAVE_API_KEY: Brave Search API 키
|
# - BRAVE_API_KEY: Brave Search API 키
|
||||||
# - DEPOSIT_API_SECRET: Deposit API 인증 키 (namecheap-api 연동)
|
# - DEPOSIT_API_SECRET: Deposit API 인증 키 (namecheap-api 연동)
|
||||||
|
# - DOMAIN_OWNER_ID: 도메인 관리 권한 Telegram ID (보안상 secrets 권장)
|
||||||
|
# - DEPOSIT_ADMIN_ID: 예치금 관리 권한 Telegram ID (보안상 secrets 권장)
|
||||||
|
|||||||
Reference in New Issue
Block a user