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 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-21 17:35:51 +09:00
parent 91f50ddc12
commit dab279c765
12 changed files with 121 additions and 27 deletions

View File

@@ -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 설정 |
**인증 필요 엔드포인트:**

View File

@@ -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://<YOUR_WORKER_URL>/setup-webhook
# 웹훅 설정 (token + secret 필요)
curl "https://<YOUR_WORKER_URL>/setup-webhook?token=${BOT_TOKEN}&secret=${WEBHOOK_SECRET}"
```
#### ⚠️ 주의사항

31
src/constants/messages.ts Normal file
View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -270,11 +270,18 @@ async function handleDepositDeduct(request: Request, env: Env): Promise<Response
/**
* POST /api/test - 테스트 API (메시지 처리 후 응답 직접 반환)
*
* ⚠️ 개발 환경 전용 - 프로덕션에서는 비활성화
*
* @param request - HTTP Request with body
* @param env - Environment bindings
* @returns JSON response with AI response
*/
async function handleTestApi(request: Request, env: Env): Promise<Response> {
// 프로덕션 환경에서는 비활성화
if (env.ENVIRONMENT === 'production') {
return new Response('Not Found', { status: 404 });
}
try {
// JSON 파싱 (별도 에러 핸들링)
let jsonData: unknown;

View File

@@ -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;
}

View File

@@ -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)}`;
}

View File

@@ -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<string> {
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)}`;
}

View File

@@ -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}`;
}
}

View File

@@ -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;

View File

@@ -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 연동 (선택)