From dab279c765ab02b32935c800d1a4d7ba1680decf Mon Sep 17 00:00:00 2001 From: kappa Date: Wed, 21 Jan 2026 17:35:51 +0900 Subject: [PATCH] fix: security hardening and performance improvements Security: - Add token+secret auth to /setup-webhook and /webhook-info endpoints - Disable /api/test in production environment (ENVIRONMENT=production) Performance: - Add retryWithBackoff to weather-tool (maxRetries: 2) - Add KV caching to executeLookupDocs (1h TTL) Code Quality: - Centralize error messages in src/constants/messages.ts - Update 5 files to use centralized error constants Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 13 ++++++++----- README.md | 8 ++++---- src/constants/messages.ts | 31 +++++++++++++++++++++++++++++++ src/index.ts | 24 ++++++++++++++++++++++++ src/openai-service.ts | 13 +++++++------ src/routes/api.ts | 7 +++++++ src/routes/webhook.ts | 3 ++- src/tools/domain-tool.ts | 5 +++-- src/tools/search-tool.ts | 31 ++++++++++++++++++++++++++----- src/tools/weather-tool.ts | 11 +++++++---- src/types.ts | 1 + wrangler.toml | 1 + 12 files changed, 121 insertions(+), 27 deletions(-) create mode 100644 src/constants/messages.ts diff --git a/CLAUDE.md b/CLAUDE.md index 0632c2e..6e5303d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -213,8 +213,11 @@ wrangler secret put DEPOSIT_API_SECRET # Deposit API 인증 키 **Webhook 설정:** ```bash -curl https://telegram-summary-bot.kappa-d8e.workers.dev/setup-webhook -curl https://telegram-summary-bot.kappa-d8e.workers.dev/webhook-info +# Webhook 설정 (token + secret 필요) +curl "https://telegram-summary-bot.kappa-d8e.workers.dev/setup-webhook?token=${BOT_TOKEN}&secret=${WEBHOOK_SECRET}" + +# Webhook 정보 조회 (token + secret 필요) +curl "https://telegram-summary-bot.kappa-d8e.workers.dev/webhook-info?token=${BOT_TOKEN}&secret=${WEBHOOK_SECRET}" ``` **Database Migrations:** @@ -377,8 +380,8 @@ curl -X POST http://localhost:8787/webhook \ # 로그 스트리밍 npm run tail -# Webhook 상태 확인 -curl https://telegram-summary-bot.kappa-d8e.workers.dev/webhook-info +# Webhook 상태 확인 (token + secret 필요) +curl "https://telegram-summary-bot.kappa-d8e.workers.dev/webhook-info?token=${BOT_TOKEN}&secret=${WEBHOOK_SECRET}" ``` **수동 테스트 예제** (자동화 예정): @@ -423,7 +426,7 @@ wrangler d1 execute telegram-conversations --command "SELECT * FROM users LIMIT | 엔드포인트 | 보안 수준 | 설명 | |-----------|----------|------| | `/health` | 최소 정보만 | status, timestamp만 반환 (DB 정보 미노출) | -| `/webhook-info` | BOT_TOKEN 필요 | Telegram Webhook 상태 조회 | +| `/webhook-info` | BOT_TOKEN + WEBHOOK_SECRET 필요 | Telegram Webhook 상태 조회 | | `/setup-webhook` | BOT_TOKEN + WEBHOOK_SECRET 필요 | Webhook 설정 | **인증 필요 엔드포인트:** diff --git a/README.md b/README.md index 8d6433c..c2c70ea 100644 --- a/README.md +++ b/README.md @@ -135,8 +135,8 @@ npm run deploy # Health check curl https://telegram-summary-bot.kappa-d8e.workers.dev/health -# Webhook 상태 -curl https://telegram-summary-bot.kappa-d8e.workers.dev/webhook-info +# Webhook 상태 (token + secret 필요) +curl "https://telegram-summary-bot.kappa-d8e.workers.dev/webhook-info?token=${BOT_TOKEN}&secret=${WEBHOOK_SECRET}" # 실시간 로그 npm run tail @@ -145,8 +145,8 @@ npm run tail ### 6. Webhook 연결 ```bash -# 웹훅 설정 (배포된 URL 사용) -curl https:///setup-webhook +# 웹훅 설정 (token + secret 필요) +curl "https:///setup-webhook?token=${BOT_TOKEN}&secret=${WEBHOOK_SECRET}" ``` #### ⚠️ 주의사항 diff --git a/src/constants/messages.ts b/src/constants/messages.ts new file mode 100644 index 0000000..6408d8f --- /dev/null +++ b/src/constants/messages.ts @@ -0,0 +1,31 @@ +/** + * 에러 메시지 상수 + * + * 사용자 친화적 에러 메시지를 중앙에서 관리합니다. + * 모든 에러 메시지는 이 파일에서 정의하고 가져와 사용하세요. + */ + +export const ERROR_MESSAGES = { + // 일반 서비스 에러 + SERVICE_UNAVAILABLE: '죄송합니다. 일시적으로 서비스를 이용할 수 없습니다. 잠시 후 다시 시도해주세요.', + AI_RESPONSE_FAILED: '죄송합니다. AI 응답 생성에 실패했습니다. 잠시 후 다시 시도해주세요.', + UNEXPECTED_ERROR: '죄송합니다. 예상치 못한 오류가 발생했습니다.', + TEMPORARY_ERROR: '⚠️ 일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', + + // 프로필 생성 관련 + PROFILE_GENERATION_FAILED: '프로필 생성 실패: 일시적으로 서비스를 이용할 수 없습니다.', + PROFILE_GENERATION_UNEXPECTED: '프로필 생성 실패: 예상치 못한 오류', + + // 검색 관련 + SEARCH_SERVICE_UNAVAILABLE: '🔍 검색 서비스에 일시적으로 접근할 수 없습니다. 잠시 후 다시 시도해주세요.', + DOCS_SERVICE_UNAVAILABLE: '📚 문서 조회 서비스에 일시적으로 접근할 수 없습니다. 잠시 후 다시 시도해주세요.', + + // 도메인 관련 + WHOIS_SERVICE_UNAVAILABLE: 'WHOIS 조회 서비스에 일시적으로 접근할 수 없습니다.', + DOMAIN_SERVICE_UNAVAILABLE: '🚫 도메인 추천 서비스에 일시적으로 접근할 수 없습니다. 잠시 후 다시 시도해주세요.', + + // 날씨 관련 + WEATHER_SERVICE_UNAVAILABLE: '날씨 정보를 가져올 수 없습니다', +} as const; + +export type ErrorMessageKey = keyof typeof ERROR_MESSAGES; diff --git a/src/index.ts b/src/index.ts index b00c6f5..6ba28dc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,16 @@ export default { return Response.json({ error: 'WEBHOOK_SECRET not configured' }, { status: 500 }); } + // 인증: token + secret 검증 + const token = url.searchParams.get('token'); + const secret = url.searchParams.get('secret'); + if (!token || token !== env.BOT_TOKEN) { + return new Response('Unauthorized: Invalid or missing token', { status: 401 }); + } + if (!secret || secret !== env.WEBHOOK_SECRET) { + return new Response('Unauthorized: Invalid or missing secret', { status: 401 }); + } + const webhookUrl = `${url.origin}/webhook`; const result = await setWebhook(env.BOT_TOKEN, webhookUrl, env.WEBHOOK_SECRET); return Response.json(result); @@ -31,6 +41,20 @@ export default { if (!env.BOT_TOKEN) { return Response.json({ error: 'BOT_TOKEN not configured' }, { status: 500 }); } + if (!env.WEBHOOK_SECRET) { + return Response.json({ error: 'WEBHOOK_SECRET not configured' }, { status: 500 }); + } + + // 인증: token + secret 검증 + const token = url.searchParams.get('token'); + const secret = url.searchParams.get('secret'); + if (!token || token !== env.BOT_TOKEN) { + return new Response('Unauthorized: Invalid or missing token', { status: 401 }); + } + if (!secret || secret !== env.WEBHOOK_SECRET) { + return new Response('Unauthorized: Invalid or missing secret', { status: 401 }); + } + const result = await getWebhookInfo(env.BOT_TOKEN); return Response.json(result); } diff --git a/src/openai-service.ts b/src/openai-service.ts index dbd6afd..0a3f4dc 100644 --- a/src/openai-service.ts +++ b/src/openai-service.ts @@ -5,6 +5,7 @@ import { CircuitBreaker, CircuitBreakerError } from './utils/circuit-breaker'; import { createLogger } from './utils/logger'; import { metrics } from './utils/metrics'; import { getOpenAIUrl } from './utils/api-urls'; +import { ERROR_MESSAGES } from './constants/messages'; const logger = createLogger('openai'); @@ -175,17 +176,17 @@ export async function generateOpenAIResponse( // 에러 처리 if (error instanceof CircuitBreakerError) { logger.error('Circuit breaker open', error as Error); - return '죄송합니다. 일시적으로 서비스를 이용할 수 없습니다. 잠시 후 다시 시도해주세요.'; + return ERROR_MESSAGES.SERVICE_UNAVAILABLE; } if (error instanceof RetryError) { logger.error('All retry attempts failed', error as Error); - return '죄송합니다. AI 응답 생성에 실패했습니다. 잠시 후 다시 시도해주세요.'; + return ERROR_MESSAGES.AI_RESPONSE_FAILED; } // 기타 에러 logger.error('Unexpected error', error as Error); - return '죄송합니다. 예상치 못한 오류가 발생했습니다.'; + return ERROR_MESSAGES.UNEXPECTED_ERROR; } } @@ -216,16 +217,16 @@ export async function generateProfileWithOpenAI( // 에러 처리 if (error instanceof CircuitBreakerError) { logger.error('Profile - Circuit breaker open', error as Error); - return '프로필 생성 실패: 일시적으로 서비스를 이용할 수 없습니다.'; + return ERROR_MESSAGES.PROFILE_GENERATION_FAILED; } if (error instanceof RetryError) { logger.error('Profile - All retry attempts failed', error as Error); - return '프로필 생성 실패: 재시도 횟수 초과'; + return ERROR_MESSAGES.PROFILE_GENERATION_FAILED; } // 기타 에러 logger.error('Profile - Unexpected error', error as Error); - return '프로필 생성 실패: 예상치 못한 오류'; + return ERROR_MESSAGES.PROFILE_GENERATION_UNEXPECTED; } } diff --git a/src/routes/api.ts b/src/routes/api.ts index 09c7067..0929190 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -270,11 +270,18 @@ async function handleDepositDeduct(request: Request, env: Env): Promise { + // 프로덕션 환경에서는 비활성화 + if (env.ENVIRONMENT === 'production') { + return new Response('Not Found', { status: 404 }); + } + try { // JSON 파싱 (별도 에러 핸들링) let jsonData: unknown; diff --git a/src/routes/webhook.ts b/src/routes/webhook.ts index 14942ed..3826bed 100644 --- a/src/routes/webhook.ts +++ b/src/routes/webhook.ts @@ -5,6 +5,7 @@ import { executeDomainRegister } from '../domain-register'; import { handleCommand } from '../commands'; import { UserService } from '../services/user-service'; import { ConversationService } from '../services/conversation-service'; +import { ERROR_MESSAGES } from '../constants/messages'; /** * Safely parse integer with range validation @@ -61,7 +62,7 @@ async function handleMessage( await sendMessage( env.BOT_TOKEN, chatId, - '⚠️ 일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요.' + ERROR_MESSAGES.TEMPORARY_ERROR ); return; } diff --git a/src/tools/domain-tool.ts b/src/tools/domain-tool.ts index d135b95..a4ba718 100644 --- a/src/tools/domain-tool.ts +++ b/src/tools/domain-tool.ts @@ -8,6 +8,7 @@ import type { import { retryWithBackoff, RetryError } from '../utils/retry'; import { createLogger, maskUserId } from '../utils/logger'; import { getOpenAIUrl } from '../utils/api-urls'; +import { ERROR_MESSAGES } from '../constants/messages'; const logger = createLogger('domain-tool'); @@ -405,7 +406,7 @@ async function callNamecheapApi( } catch (error) { logger.error('오류', error as Error, { domain: funcArgs.domain }); if (error instanceof RetryError) { - return { error: 'WHOIS 조회 서비스에 일시적으로 접근할 수 없습니다.' }; + return { error: ERROR_MESSAGES.WHOIS_SERVICE_UNAVAILABLE }; } return { error: `WHOIS 조회 오류: ${String(error)}` }; } @@ -1063,7 +1064,7 @@ ${excludeList ? `- 다음 도메인은 제외하세요: ${excludeList}` : ''} } catch (error) { logger.error('오류', error as Error, { keywords }); if (error instanceof RetryError) { - return `🚫 도메인 추천 서비스에 일시적으로 접근할 수 없습니다. 잠시 후 다시 시도해주세요.`; + return ERROR_MESSAGES.DOMAIN_SERVICE_UNAVAILABLE; } return `🚫 도메인 추천 중 오류가 발생했습니다: ${String(error)}`; } diff --git a/src/tools/search-tool.ts b/src/tools/search-tool.ts index 80d196c..9b9cf60 100644 --- a/src/tools/search-tool.ts +++ b/src/tools/search-tool.ts @@ -9,6 +9,7 @@ import type { import { retryWithBackoff, RetryError } from '../utils/retry'; import { createLogger } from '../utils/logger'; import { getOpenAIUrl } from '../utils/api-urls'; +import { ERROR_MESSAGES } from '../constants/messages'; const logger = createLogger('search-tool'); @@ -163,7 +164,7 @@ export async function executeSearchWeb(args: { query: string }, env?: Env): Prom } catch (error) { logger.error('오류', error as Error); if (error instanceof RetryError) { - return `🔍 검색 서비스에 일시적으로 접근할 수 없습니다. 잠시 후 다시 시도해주세요.`; + return ERROR_MESSAGES.SEARCH_SERVICE_UNAVAILABLE; } return `검색 중 오류가 발생했습니다: ${String(error)}`; } @@ -172,8 +173,20 @@ export async function executeSearchWeb(args: { query: string }, env?: Env): Prom export async function executeLookupDocs(args: { library: string; query: string }, env?: Env): Promise { const { library, query } = args; try { + // 캐시 키 생성 + const cacheKey = `docs:${library}:${query}`; + + // 1. 캐시 확인 + if (env?.RATE_LIMIT_KV) { + const cached = await env.RATE_LIMIT_KV.get(cacheKey); + if (cached) { + logger.info('문서 캐시 히트', { library, query }); + return cached; + } + } + // Context7 REST API 직접 호출 - // 1. 라이브러리 검색 + // 2. 라이브러리 검색 const searchUrl = `${env?.CONTEXT7_API_BASE || 'https://context7.com/api/v2'}/libs/search?libraryName=${encodeURIComponent(library)}&query=${encodeURIComponent(query)}`; const searchResponse = await retryWithBackoff( () => fetch(searchUrl), @@ -187,7 +200,7 @@ export async function executeLookupDocs(args: { library: string; query: string } const libraryId = searchData.libraries[0].id; - // 2. 문서 조회 + // 3. 문서 조회 const docsUrl = `${env?.CONTEXT7_API_BASE || 'https://context7.com/api/v2'}/context?libraryId=${encodeURIComponent(libraryId)}&query=${encodeURIComponent(query)}`; const docsResponse = await retryWithBackoff( () => fetch(docsUrl), @@ -200,11 +213,19 @@ export async function executeLookupDocs(args: { library: string; query: string } } const content = docsData.context || docsData.content || JSON.stringify(docsData, null, 2); - return `📚 ${library} 문서 (${query}):\n\n${content.slice(0, 1500)}`; + const result = `📚 ${library} 문서 (${query}):\n\n${content.slice(0, 1500)}`; + + // 4. 결과 캐싱 (1시간 TTL) + if (env?.RATE_LIMIT_KV) { + await env.RATE_LIMIT_KV.put(cacheKey, result, { expirationTtl: 3600 }); + logger.info('문서 캐싱', { library, query }); + } + + return result; } catch (error) { logger.error('오류', error as Error); if (error instanceof RetryError) { - return `📚 문서 조회 서비스에 일시적으로 접근할 수 없습니다. 잠시 후 다시 시도해주세요.`; + return ERROR_MESSAGES.DOCS_SERVICE_UNAVAILABLE; } return `📚 문서 조회 중 오류: ${String(error)}`; } diff --git a/src/tools/weather-tool.ts b/src/tools/weather-tool.ts index 85abc2f..19e5636 100644 --- a/src/tools/weather-tool.ts +++ b/src/tools/weather-tool.ts @@ -1,5 +1,7 @@ // Weather Tool - wttr.in integration import type { Env } from '../types'; +import { retryWithBackoff } from '../utils/retry'; +import { ERROR_MESSAGES } from '../constants/messages'; // wttr.in API 응답 타입 정의 interface WttrCurrentCondition { @@ -56,8 +58,9 @@ export async function executeWeather(args: { city: string }, env?: Env): Promise const city = args.city || 'Seoul'; try { const wttrUrl = env?.WTTR_IN_URL || 'https://wttr.in'; - const response = await fetch( - `${wttrUrl}/${encodeURIComponent(city)}?format=j1` + const response = await retryWithBackoff( + () => fetch(`${wttrUrl}/${encodeURIComponent(city)}?format=j1`), + { maxRetries: 2, initialDelayMs: 500 } ); if (!response.ok) { @@ -68,7 +71,7 @@ export async function executeWeather(args: { city: string }, env?: Env): Promise // 안전한 접근 - 데이터 유효성 확인 if (!data.current_condition?.[0]) { - return `날씨 정보를 가져올 수 없습니다: ${city}`; + return `${ERROR_MESSAGES.WEATHER_SERVICE_UNAVAILABLE}: ${city}`; } const current = data.current_condition[0]; @@ -84,6 +87,6 @@ export async function executeWeather(args: { city: string }, env?: Env): Promise 습도: ${current.humidity}% 풍속: ${current.windspeedKmph} km/h`; } catch (error) { - return `날씨 정보를 가져올 수 없습니다: ${city}`; + return `${ERROR_MESSAGES.WEATHER_SERVICE_UNAVAILABLE}: ${city}`; } } diff --git a/src/types.ts b/src/types.ts index 20fada0..8d65f46 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,6 +3,7 @@ export interface Env { AI: Ai; BOT_TOKEN: string; WEBHOOK_SECRET: string; + ENVIRONMENT?: string; SUMMARY_THRESHOLD?: string; MAX_SUMMARIES_PER_USER?: string; N8N_WEBHOOK_URL?: string; diff --git a/wrangler.toml b/wrangler.toml index 6d1dc20..e3db9b8 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -6,6 +6,7 @@ compatibility_date = "2024-01-01" binding = "AI" [vars] +ENVIRONMENT = "production" # 환경 설정 (production | development) SUMMARY_THRESHOLD = "20" # 프로필 업데이트 주기 (메시지 수) MAX_SUMMARIES_PER_USER = "3" # 유지할 프로필 버전 수 (슬라이딩 윈도우) N8N_WEBHOOK_URL = "https://n8n.anvil.it.com" # n8n 연동 (선택)