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:
kappa
2026-01-19 15:20:14 +09:00
parent 6d4fd7f22f
commit 4eb5bbd3d3
11 changed files with 1277 additions and 66 deletions

View File

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