feat: 보안 강화 + 자동화 도구 3종 추가 (웹 크롤러·PPT·엑셀)
- lib/security.ts: escapeHtml, isValidEmail, sanitizeStr, checkRateLimit 유틸 추가 - next.config.ts: 보안 헤더 적용 (X-Frame-Options, HSTS, Permissions-Policy 등) - api/contact: XSS 방어, Rate Limit(5/min), 입력 길이 제한 - api/payment/confirm: 사용자 인증·소유권 검증, 타입 체크, 에러 메시지 정제 - api/admin/quotes: PUT 허용 필드 화이트리스트 적용 - api/saju/analyze: 로그인·결제 검증, 입력 크기 제한, gender 값 검증 - public/downloads/web_scraper_v1.0.py: requests+BS4+openpyxl 웹 크롤러 - public/downloads/ppt_automation_v1.0.py: python-pptx+openpyxl PPT 자동화 - app/services/automation/tools/scraper: 크롤러 상세 페이지 추가 - app/services/automation/tools/ppt: PPT 도구 상세 페이지 추가 - app/services/automation/page.tsx: scraper ready=true, email→PPT 교체 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
95
lib/security.ts
Normal file
95
lib/security.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* lib/security.ts — 공통 보안 유틸리티
|
||||
* XSS 방지, 입력 검증, Rate Limiting
|
||||
*/
|
||||
|
||||
// ── HTML 이스케이프 (이메일 템플릿 XSS 방지) ──────────────────────
|
||||
export function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// ── 입력 검증 ─────────────────────────────────────────────────────
|
||||
export function isValidEmail(email: string): boolean {
|
||||
return /^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{2,}$/.test(email);
|
||||
}
|
||||
|
||||
export function isValidPhone(phone: string): boolean {
|
||||
const digits = phone.replace(/[\s\-]/g, '');
|
||||
return /^0\d{9,10}$/.test(digits);
|
||||
}
|
||||
|
||||
/** 문자열을 maxLen으로 자르고 트림 */
|
||||
export function sanitizeStr(str: unknown, maxLen: number): string {
|
||||
if (typeof str !== 'string') return '';
|
||||
return str.slice(0, maxLen).trim();
|
||||
}
|
||||
|
||||
// ── 입력 길이 상수 ────────────────────────────────────────────────
|
||||
export const INPUT_LIMITS = {
|
||||
NAME: 50,
|
||||
PHONE: 20,
|
||||
EMAIL: 100,
|
||||
SERVICE: 100,
|
||||
MESSAGE: 3000,
|
||||
} as const;
|
||||
|
||||
// ── In-memory Rate Limiter ────────────────────────────────────────
|
||||
// Vercel serverless 환경에서 인스턴스별 기본 보호용
|
||||
// 더 강력한 보호가 필요하면 Upstash Redis + @upstash/ratelimit 사용
|
||||
interface RLEntry { count: number; resetAt: number }
|
||||
const rlMap = new Map<string, RLEntry>();
|
||||
|
||||
// 메모리 누수 방지: 10분마다 만료된 엔트리 정리
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of rlMap.entries()) {
|
||||
if (now > entry.resetAt) rlMap.delete(key);
|
||||
}
|
||||
}, 10 * 60 * 1000);
|
||||
|
||||
export interface RateLimitResult {
|
||||
allowed: boolean;
|
||||
remaining: number;
|
||||
retryAfterMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param key 식별자 (IP + endpoint 조합 권장)
|
||||
* @param windowMs 시간 창 (ms)
|
||||
* @param max 창 내 최대 허용 횟수
|
||||
*/
|
||||
export function checkRateLimit(
|
||||
key: string,
|
||||
windowMs: number,
|
||||
max: number,
|
||||
): RateLimitResult {
|
||||
const now = Date.now();
|
||||
const entry = rlMap.get(key);
|
||||
|
||||
if (!entry || now > entry.resetAt) {
|
||||
rlMap.set(key, { count: 1, resetAt: now + windowMs });
|
||||
return { allowed: true, remaining: max - 1, retryAfterMs: 0 };
|
||||
}
|
||||
|
||||
if (entry.count >= max) {
|
||||
return { allowed: false, remaining: 0, retryAfterMs: entry.resetAt - now };
|
||||
}
|
||||
|
||||
entry.count++;
|
||||
return { allowed: true, remaining: max - entry.count, retryAfterMs: 0 };
|
||||
}
|
||||
|
||||
/** Request에서 클라이언트 IP 추출 (Vercel 헤더 우선) */
|
||||
export function getClientIp(request: Request): string {
|
||||
const headers = request.headers as Headers;
|
||||
return (
|
||||
headers.get('x-real-ip') ??
|
||||
headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
|
||||
'unknown'
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user