feat(security): API 키 보호, CORS 강화, Rate Limiting KV 전환
보안 개선: - API 키 하드코딩 제거 (NAMECHEAP_API_KEY_INTERNAL) - CORS 정책: * → hosting.anvil.it.com 제한 - /health 엔드포인트 DB 정보 노출 방지 - Rate Limiting 인메모리 Map → Cloudflare KV 전환 - 분산 환경 일관성 보장 - 재시작 후에도 유지 - 자동 만료 (TTL) 문서: - CLAUDE.md Security 섹션 추가 - KV Namespace 설정 가이드 추가 - 배포/마이그레이션 가이드 추가 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
39
src/index.ts
39
src/index.ts
@@ -52,8 +52,8 @@ async function handleMessage(
|
||||
const text = message.text;
|
||||
const telegramUserId = message.from.id.toString();
|
||||
|
||||
// Rate Limiting 체크
|
||||
if (!checkRateLimit(telegramUserId)) {
|
||||
// Rate Limiting 체크 (KV 기반)
|
||||
if (!(await checkRateLimit(env.RATE_LIMIT_KV, telegramUserId))) {
|
||||
await sendMessage(
|
||||
env.BOT_TOKEN,
|
||||
chatId,
|
||||
@@ -276,31 +276,12 @@ export default {
|
||||
return Response.json(result);
|
||||
}
|
||||
|
||||
// 헬스 체크
|
||||
// 헬스 체크 (공개 - 최소 정보만)
|
||||
if (url.pathname === '/health') {
|
||||
try {
|
||||
const userCount = await env.DB
|
||||
.prepare('SELECT COUNT(*) as cnt FROM users')
|
||||
.first<{ cnt: number }>();
|
||||
|
||||
const summaryCount = await env.DB
|
||||
.prepare('SELECT COUNT(*) as cnt FROM summaries')
|
||||
.first<{ cnt: number }>();
|
||||
|
||||
return Response.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
stats: {
|
||||
users: userCount?.cnt || 0,
|
||||
summaries: summaryCount?.cnt || 0,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return Response.json({
|
||||
status: 'error',
|
||||
error: String(error),
|
||||
}, { status: 500 });
|
||||
}
|
||||
return Response.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Deposit API - 잔액 조회 (namecheap-api 전용)
|
||||
@@ -478,9 +459,9 @@ export default {
|
||||
|
||||
// 문의 폼 API (웹사이트용)
|
||||
if (url.pathname === '/api/contact' && request.method === 'POST') {
|
||||
// CORS preflight는 OPTIONS에서 처리
|
||||
// CORS: hosting.anvil.it.com만 허용
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Origin': 'https://hosting.anvil.it.com',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
};
|
||||
@@ -549,7 +530,7 @@ export default {
|
||||
if (url.pathname === '/api/contact' && request.method === 'OPTIONS') {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Origin': 'https://hosting.anvil.it.com',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
},
|
||||
|
||||
@@ -394,11 +394,15 @@ async function callNamecheapApi(
|
||||
funcName: string,
|
||||
funcArgs: Record<string, any>,
|
||||
allowedDomains: string[],
|
||||
env?: Env,
|
||||
telegramUserId?: string,
|
||||
db?: D1Database,
|
||||
userId?: number
|
||||
): Promise<any> {
|
||||
const apiKey = '05426957210b42e752950f565ea82a3f48df9cccfdce9d82cd9817011968076e';
|
||||
if (!env?.NAMECHEAP_API_KEY_INTERNAL) {
|
||||
return { error: 'Namecheap API 키가 설정되지 않았습니다.' };
|
||||
}
|
||||
const apiKey = env.NAMECHEAP_API_KEY_INTERNAL;
|
||||
const apiUrl = 'https://namecheap-api.anvil.it.com';
|
||||
|
||||
// 도메인 권한 체크 (쓰기 작업만)
|
||||
@@ -614,6 +618,7 @@ async function executeDomainAction(
|
||||
action: string,
|
||||
args: { domain?: string; nameservers?: string[]; tld?: string },
|
||||
allowedDomains: string[],
|
||||
env?: Env,
|
||||
telegramUserId?: string,
|
||||
db?: D1Database,
|
||||
userId?: number
|
||||
@@ -622,7 +627,7 @@ async function executeDomainAction(
|
||||
|
||||
switch (action) {
|
||||
case 'list': {
|
||||
const result = await callNamecheapApi('list_domains', {}, allowedDomains, telegramUserId, db, userId);
|
||||
const result = await callNamecheapApi('list_domains', {}, allowedDomains, env, telegramUserId, db, userId);
|
||||
if (result.error) return `🚫 ${result.error}`;
|
||||
if (!result.length) return '📋 등록된 도메인이 없습니다.';
|
||||
const list = result.map((d: any) => `• ${d.name} (만료: ${d.expires})`).join('\n');
|
||||
@@ -631,14 +636,14 @@ async function executeDomainAction(
|
||||
|
||||
case 'info': {
|
||||
if (!domain) return '🚫 도메인을 지정해주세요.';
|
||||
const result = await callNamecheapApi('get_domain_info', { domain }, allowedDomains, telegramUserId, db, userId);
|
||||
const result = await callNamecheapApi('get_domain_info', { domain }, allowedDomains, env, telegramUserId, db, userId);
|
||||
if (result.error) return `🚫 ${result.error}`;
|
||||
return `📋 ${domain} 정보\n\n• 생성일: ${result.created}\n• 만료일: ${result.expires}\n• 자동갱신: ${result.auto_renew ? '✅' : '❌'}\n• 잠금: ${result.is_locked ? '🔒' : '🔓'}\n• WHOIS Guard: ${result.whois_guard ? '✅' : '❌'}`;
|
||||
}
|
||||
|
||||
case 'get_ns': {
|
||||
if (!domain) return '🚫 도메인을 지정해주세요.';
|
||||
const result = await callNamecheapApi('get_nameservers', { domain }, allowedDomains, telegramUserId, db, userId);
|
||||
const result = await callNamecheapApi('get_nameservers', { domain }, allowedDomains, env, telegramUserId, db, userId);
|
||||
if (result.error) return `🚫 ${result.error}`;
|
||||
const nsList = (result.nameservers || result).map((ns: string) => `• ${ns}`).join('\n');
|
||||
return `🌐 ${domain} 네임서버\n\n${nsList}`;
|
||||
@@ -648,20 +653,20 @@ async function executeDomainAction(
|
||||
if (!domain) return '🚫 도메인을 지정해주세요.';
|
||||
if (!nameservers?.length) return '🚫 네임서버를 지정해주세요.';
|
||||
if (!allowedDomains.includes(domain)) return `🚫 ${domain}은 관리 권한이 없습니다.`;
|
||||
const result = await callNamecheapApi('set_nameservers', { domain, nameservers }, allowedDomains, telegramUserId, db, userId);
|
||||
const result = await callNamecheapApi('set_nameservers', { domain, nameservers }, allowedDomains, env, telegramUserId, db, userId);
|
||||
if (result.error) return `🚫 ${result.error}`;
|
||||
return `✅ ${domain} 네임서버 변경 완료\n\n${nameservers.map(ns => `• ${ns}`).join('\n')}`;
|
||||
}
|
||||
|
||||
case 'check': {
|
||||
if (!domain) return '🚫 도메인을 지정해주세요.';
|
||||
const result = await callNamecheapApi('check_domains', { domains: [domain] }, allowedDomains, telegramUserId, db, userId);
|
||||
const result = await callNamecheapApi('check_domains', { domains: [domain] }, allowedDomains, env, telegramUserId, db, userId);
|
||||
if (result.error) return `🚫 ${result.error}`;
|
||||
const available = result[domain];
|
||||
if (available) {
|
||||
// 가격도 함께 조회
|
||||
const domainTld = domain.split('.').pop() || '';
|
||||
const priceResult = await callNamecheapApi('get_price', { tld: domainTld }, allowedDomains, telegramUserId, db, userId);
|
||||
const priceResult = await callNamecheapApi('get_price', { tld: domainTld }, allowedDomains, env, telegramUserId, db, userId);
|
||||
const price = priceResult.krw || priceResult.register_krw;
|
||||
return `✅ ${domain}은 등록 가능합니다.\n\n💰 가격: ${price?.toLocaleString()}원/년\n\n등록하시려면 "${domain} 등록해줘"라고 말씀해주세요.`;
|
||||
}
|
||||
@@ -670,7 +675,7 @@ async function executeDomainAction(
|
||||
|
||||
case 'whois': {
|
||||
if (!domain) return '🚫 도메인을 지정해주세요.';
|
||||
const result = await callNamecheapApi('whois_lookup', { domain }, allowedDomains, telegramUserId, db, userId);
|
||||
const result = await callNamecheapApi('whois_lookup', { domain }, allowedDomains, env, telegramUserId, db, userId);
|
||||
if (result.error) return `🚫 ${result.error}`;
|
||||
|
||||
// ccSLD WHOIS 미지원
|
||||
@@ -759,7 +764,7 @@ async function executeDomainAction(
|
||||
// tld, domain, 또는 ".com" 형식 모두 지원
|
||||
let targetTld = tld || domain?.replace(/^\./, '').split('.').pop();
|
||||
if (!targetTld) return '🚫 TLD를 지정해주세요. (예: com, io, net)';
|
||||
const result = await callNamecheapApi('get_price', { tld: targetTld }, allowedDomains, telegramUserId, db, userId);
|
||||
const result = await callNamecheapApi('get_price', { tld: targetTld }, allowedDomains, env, telegramUserId, db, userId);
|
||||
if (result.error) return `🚫 ${result.error}`;
|
||||
// API 응답: { tld, usd, krw }
|
||||
const price = result.krw || result.register_krw;
|
||||
@@ -767,7 +772,7 @@ async function executeDomainAction(
|
||||
}
|
||||
|
||||
case 'cheapest': {
|
||||
const result = await callNamecheapApi('get_all_prices', {}, allowedDomains, telegramUserId, db, userId);
|
||||
const result = await callNamecheapApi('get_all_prices', {}, allowedDomains, env, telegramUserId, db, userId);
|
||||
if (result.error) return `🚫 ${result.error}`;
|
||||
|
||||
// 가격 > 0인 TLD만 필터링, krw 기준 정렬
|
||||
@@ -792,13 +797,13 @@ async function executeDomainAction(
|
||||
if (!telegramUserId) return '🚫 도메인 등록에는 로그인이 필요합니다.';
|
||||
|
||||
// 1. 가용성 확인
|
||||
const checkResult = await callNamecheapApi('check_domains', { domains: [domain] }, allowedDomains, telegramUserId, db, userId);
|
||||
const checkResult = await callNamecheapApi('check_domains', { domains: [domain] }, allowedDomains, env, telegramUserId, db, userId);
|
||||
if (checkResult.error) return `🚫 ${checkResult.error}`;
|
||||
if (!checkResult[domain]) return `❌ ${domain}은 이미 등록된 도메인입니다.`;
|
||||
|
||||
// 2. 가격 조회
|
||||
const domainTld = domain.split('.').pop() || '';
|
||||
const priceResult = await callNamecheapApi('get_price', { tld: domainTld }, allowedDomains, telegramUserId, db, userId);
|
||||
const priceResult = await callNamecheapApi('get_price', { tld: domainTld }, allowedDomains, env, telegramUserId, db, userId);
|
||||
if (priceResult.error) return `🚫 가격 조회 실패: ${priceResult.error}`;
|
||||
const price = priceResult.krw || priceResult.register_krw;
|
||||
|
||||
@@ -1211,6 +1216,7 @@ async function executeTool(name: string, args: Record<string, string>, env?: Env
|
||||
action,
|
||||
{ domain, nameservers, tld },
|
||||
userDomains,
|
||||
env,
|
||||
telegramUserId,
|
||||
db,
|
||||
userId
|
||||
|
||||
@@ -118,36 +118,56 @@ export async function validateWebhookRequest(
|
||||
return { valid: true, update: body };
|
||||
}
|
||||
|
||||
// Rate Limiting
|
||||
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
|
||||
// Rate Limiting (Cloudflare KV 기반)
|
||||
interface RateLimitData {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
export function checkRateLimit(
|
||||
export async function checkRateLimit(
|
||||
kv: KVNamespace,
|
||||
userId: string,
|
||||
maxRequests: number = 30,
|
||||
windowMs: number = 60000
|
||||
): boolean {
|
||||
): Promise<boolean> {
|
||||
const key = `ratelimit:${userId}`;
|
||||
const now = Date.now();
|
||||
const userLimit = rateLimitMap.get(userId);
|
||||
|
||||
if (!userLimit || now > userLimit.resetAt) {
|
||||
rateLimitMap.set(userId, { count: 1, resetAt: now + windowMs });
|
||||
try {
|
||||
// KV에서 기존 데이터 조회
|
||||
const dataStr = await kv.get(key);
|
||||
const data: RateLimitData | null = dataStr ? JSON.parse(dataStr) : null;
|
||||
|
||||
// 윈도우 만료 또는 첫 요청
|
||||
if (!data || now > data.resetAt) {
|
||||
const newData: RateLimitData = {
|
||||
count: 1,
|
||||
resetAt: now + windowMs,
|
||||
};
|
||||
await kv.put(key, JSON.stringify(newData), {
|
||||
expirationTtl: Math.ceil(windowMs / 1000), // 초 단위
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// Rate limit 초과
|
||||
if (data.count >= maxRequests) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 카운트 증가
|
||||
const updatedData: RateLimitData = {
|
||||
count: data.count + 1,
|
||||
resetAt: data.resetAt,
|
||||
};
|
||||
const remainingTtl = Math.ceil((data.resetAt - now) / 1000);
|
||||
await kv.put(key, JSON.stringify(updatedData), {
|
||||
expirationTtl: Math.max(remainingTtl, 1), // 최소 1초
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[RateLimit] KV 오류:', error);
|
||||
// KV 오류 시 허용 (서비스 가용성 우선)
|
||||
return true;
|
||||
}
|
||||
|
||||
if (userLimit.count >= maxRequests) {
|
||||
return false;
|
||||
}
|
||||
|
||||
userLimit.count++;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Rate limit 정리 (메모리 관리)
|
||||
export function cleanupRateLimits(): void {
|
||||
const now = Date.now();
|
||||
for (const [key, value] of rateLimitMap.entries()) {
|
||||
if (now > value.resetAt) {
|
||||
rateLimitMap.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,12 @@ export interface Env {
|
||||
N8N_WEBHOOK_URL?: string;
|
||||
OPENAI_API_KEY?: string;
|
||||
NAMECHEAP_API_KEY?: string;
|
||||
NAMECHEAP_API_KEY_INTERNAL?: string;
|
||||
DOMAIN_OWNER_ID?: string;
|
||||
DEPOSIT_ADMIN_ID?: string;
|
||||
BRAVE_API_KEY?: string;
|
||||
DEPOSIT_API_SECRET?: string;
|
||||
RATE_LIMIT_KV: KVNamespace;
|
||||
}
|
||||
|
||||
export interface IntentAnalysis {
|
||||
|
||||
Reference in New Issue
Block a user