refactor: 남은 코드 품질/보안 이슈 개선

1. hashString 함수 수정
   - Math.abs() → >>> 0 unsigned 변환

2. CSP 보안 헤더 추가
   - Content-Security-Policy: default-src 'none'

3. 캐시 키 충돌 방지
   - URL-safe base64 인코딩으로 변경

4. CORS 보안 강화
   - Origin 없는 요청에 빈 문자열 반환 (CORS 미적용)
   - 허용 목록 기반 Origin 검증

5. estimateBandwidth 리팩토링
   - USE_CASE_CONFIGS 활용으로 중복 정규식 제거
   - switch 문으로 가독성 향상
   - getDauMultiplier, getActiveUserRatio 간소화

6. 요청 본문 크기 제한
   - 10KB 초과 요청 차단 (413 응답)
   - 대용량 payload 공격 방어

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-25 16:29:45 +09:00
parent ceb5eb7248
commit 0bb7296600

View File

@@ -149,64 +149,98 @@ interface BandwidthEstimate {
active_ratio: number; // Active user ratio (0.0-1.0) active_ratio: number; // Active user ratio (0.0-1.0)
} }
// Use case configuration for bandwidth estimation and user metrics
interface UseCaseConfig {
category: 'video' | 'file' | 'gaming' | 'api' | 'ecommerce' | 'forum' | 'blog' | 'chat' | 'default';
patterns: RegExp;
dauMultiplier: { min: number; max: number };
activeRatio: number;
}
const USE_CASE_CONFIGS: UseCaseConfig[] = [
{
category: 'video',
patterns: /video|stream|media|youtube|netflix|vod|동영상|스트리밍|미디어/i,
dauMultiplier: { min: 8, max: 12 },
activeRatio: 0.3
},
{
category: 'file',
patterns: /download|file|storage|cdn|파일|다운로드|저장소/i,
dauMultiplier: { min: 10, max: 14 },
activeRatio: 0.5
},
{
category: 'gaming',
patterns: /game|gaming|minecraft|게임/i,
dauMultiplier: { min: 10, max: 20 },
activeRatio: 0.5
},
{
category: 'api',
patterns: /api|saas|backend|서비스|백엔드/i,
dauMultiplier: { min: 5, max: 10 },
activeRatio: 0.6
},
{
category: 'ecommerce',
patterns: /e-?commerce|shop|store|쇼핑|커머스|온라인몰/i,
dauMultiplier: { min: 20, max: 30 },
activeRatio: 0.4
},
{
category: 'forum',
patterns: /forum|community|board|게시판|커뮤니티|포럼/i,
dauMultiplier: { min: 15, max: 25 },
activeRatio: 0.5
},
{
category: 'blog',
patterns: /blog|news|static|portfolio|블로그|뉴스|포트폴리오|landing/i,
dauMultiplier: { min: 30, max: 50 },
activeRatio: 0.3
},
{
category: 'chat',
patterns: /chat|messaging|slack|discord|채팅|메신저/i,
dauMultiplier: { min: 10, max: 14 },
activeRatio: 0.7
}
];
/**
* Find use case configuration by matching patterns
*/
function findUseCaseConfig(useCase: string): UseCaseConfig {
const useCaseLower = useCase.toLowerCase();
for (const config of USE_CASE_CONFIGS) {
if (config.patterns.test(useCaseLower)) {
return config;
}
}
// Default configuration
return {
category: 'default',
patterns: /.*/,
dauMultiplier: { min: 10, max: 14 },
activeRatio: 0.5
};
}
/** /**
* Get DAU multiplier based on use case (how many daily active users per concurrent user) * Get DAU multiplier based on use case (how many daily active users per concurrent user)
*/ */
function getDauMultiplier(useCase: string): { min: number; max: number } { function getDauMultiplier(useCase: string): { min: number; max: number } {
const useCaseLower = useCase.toLowerCase(); return findUseCaseConfig(useCase).dauMultiplier;
if (/game|gaming|minecraft|게임/.test(useCaseLower)) {
// Gaming: users stay online longer, higher concurrent ratio
return { min: 10, max: 20 };
} else if (/blog|news|static|블로그|뉴스|포트폴리오/.test(useCaseLower)) {
// Blog/Static: short visits, lower concurrent ratio
return { min: 30, max: 50 };
} else if (/api|saas|backend|서비스|백엔드/.test(useCaseLower)) {
// SaaS/API: business hours concentration
return { min: 5, max: 10 };
} else if (/e-?commerce|shop|store|쇼핑|커머스|온라인몰/.test(useCaseLower)) {
// E-commerce: moderate session lengths
return { min: 20, max: 30 };
} else if (/forum|community|board|게시판|커뮤니티|포럼/.test(useCaseLower)) {
// Forum/Community: moderate engagement
return { min: 15, max: 25 };
} else if (/video|stream|media|youtube|netflix|vod|동영상|스트리밍|미디어/.test(useCaseLower)) {
// Video/Streaming: medium-long sessions
return { min: 8, max: 12 };
} else {
// Default: general web app
return { min: 10, max: 14 };
}
} }
/** /**
* Get active user ratio (what percentage of DAU actually performs the bandwidth-heavy action) * Get active user ratio (what percentage of DAU actually performs the bandwidth-heavy action)
*/ */
function getActiveUserRatio(useCase: string): number { function getActiveUserRatio(useCase: string): number {
const useCaseLower = useCase.toLowerCase(); return findUseCaseConfig(useCase).activeRatio;
if (/video|stream|media|youtube|netflix|vod|동영상|스트리밍|미디어/.test(useCaseLower)) {
// Video/Streaming: only 30% of DAU actually stream
return 0.3;
} else if (/game|gaming|minecraft|게임/.test(useCaseLower)) {
// Gaming: 50% of DAU are active players
return 0.5;
} else if (/e-?commerce|shop|store|쇼핑|커머스|온라인몰/.test(useCaseLower)) {
// E-commerce: 40% browse products
return 0.4;
} else if (/api|saas|backend|서비스|백엔드/.test(useCaseLower)) {
// API/SaaS: 60% active usage
return 0.6;
} else if (/forum|community|board|게시판|커뮤니티|포럼/.test(useCaseLower)) {
// Forum/Community: 50% active posting/reading
return 0.5;
} else if (/blog|static|portfolio|블로그|포트폴리오/.test(useCaseLower)) {
// Static/Blog: 30% active readers
return 0.3;
} else {
// Default: 50% active
return 0.5;
}
} }
/** /**
@@ -225,12 +259,16 @@ function getActiveUserRatio(useCase: string): number {
function estimateBandwidth(concurrentUsers: number, useCase: string, trafficPattern?: string): BandwidthEstimate { function estimateBandwidth(concurrentUsers: number, useCase: string, trafficPattern?: string): BandwidthEstimate {
const useCaseLower = useCase.toLowerCase(); const useCaseLower = useCase.toLowerCase();
// Get use case configuration
const config = findUseCaseConfig(useCase);
const useCaseCategory = config.category;
// Calculate DAU estimate from concurrent users with use-case-specific multipliers // Calculate DAU estimate from concurrent users with use-case-specific multipliers
const dauMultiplier = getDauMultiplier(useCase); const dauMultiplier = config.dauMultiplier;
const estimatedDauMin = Math.round(concurrentUsers * dauMultiplier.min); const estimatedDauMin = Math.round(concurrentUsers * dauMultiplier.min);
const estimatedDauMax = Math.round(concurrentUsers * dauMultiplier.max); const estimatedDauMax = Math.round(concurrentUsers * dauMultiplier.max);
const dailyUniqueVisitors = Math.round((estimatedDauMin + estimatedDauMax) / 2); const dailyUniqueVisitors = Math.round((estimatedDauMin + estimatedDauMax) / 2);
const activeUserRatio = getActiveUserRatio(useCase); const activeUserRatio = config.activeRatio;
const activeDau = Math.round(dailyUniqueVisitors * activeUserRatio); const activeDau = Math.round(dailyUniqueVisitors * activeUserRatio);
// Traffic pattern adjustment // Traffic pattern adjustment
@@ -247,99 +285,118 @@ function estimateBandwidth(concurrentUsers: number, useCase: string, trafficPatt
// ========== IMPROVED BANDWIDTH MODELS ========== // ========== IMPROVED BANDWIDTH MODELS ==========
// Each use case uses the most appropriate calculation method // Each use case uses the most appropriate calculation method
if (/video|stream|media|youtube|netflix|vod|동영상|스트리밍|미디어/.test(useCaseLower)) { switch (useCaseCategory) {
// VIDEO/STREAMING: Bitrate-based model case 'video': {
// - HD streaming: ~5 Mbps = 2.25 GB/hour // VIDEO/STREAMING: Bitrate-based model
// - Average watch time: 1.5 hours per session // - HD streaming: ~5 Mbps = 2.25 GB/hour
// - 4K streaming: ~25 Mbps = 11.25 GB/hour (detect if mentioned) // - Average watch time: 1.5 hours per session
const is4K = /4k|uhd|ultra/i.test(useCaseLower); // - 4K streaming: ~25 Mbps = 11.25 GB/hour (detect if mentioned)
const bitrateGBperHour = is4K ? 11.25 : 2.25; // 4K vs HD const is4K = /4k|uhd|ultra/i.test(useCaseLower);
const avgWatchTimeHours = is4K ? 1.0 : 1.5; // 4K users watch less const bitrateGBperHour = is4K ? 11.25 : 2.25; // 4K vs HD
const gbPerActiveUser = bitrateGBperHour * avgWatchTimeHours; const avgWatchTimeHours = is4K ? 1.0 : 1.5; // 4K users watch less
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; const gbPerActiveUser = bitrateGBperHour * avgWatchTimeHours;
bandwidthModel = `bitrate-based: ${activeDau} active × ${bitrateGBperHour} GB/hr × ${avgWatchTimeHours}hr`; dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
bandwidthModel = `bitrate-based: ${activeDau} active × ${bitrateGBperHour} GB/hr × ${avgWatchTimeHours}hr`;
break;
}
} else if (/download|file|storage|cdn|파일|다운로드|저장소/.test(useCaseLower)) { case 'file': {
// FILE DOWNLOAD: File-size based model // FILE DOWNLOAD: File-size based model
// - Average file size: 100-500 MB depending on type // - Average file size: 100-500 MB depending on type
// - Downloads per active user: 2-5 per day // - Downloads per active user: 2-5 per day
const isLargeFiles = /iso|video|backup|대용량/.test(useCaseLower); const isLargeFiles = /iso|video|backup|대용량/.test(useCaseLower);
const avgFileSizeGB = isLargeFiles ? 2.0 : 0.2; // 2GB for large, 200MB for normal const avgFileSizeGB = isLargeFiles ? 2.0 : 0.2; // 2GB for large, 200MB for normal
const downloadsPerUser = isLargeFiles ? 1 : 3; const downloadsPerUser = isLargeFiles ? 1 : 3;
const gbPerActiveUser = avgFileSizeGB * downloadsPerUser; const gbPerActiveUser = avgFileSizeGB * downloadsPerUser;
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
bandwidthModel = `file-based: ${activeDau} active × ${avgFileSizeGB} GB × ${downloadsPerUser} downloads`; bandwidthModel = `file-based: ${activeDau} active × ${avgFileSizeGB} GB × ${downloadsPerUser} downloads`;
break;
}
} else if (/game|gaming|minecraft|게임/.test(useCaseLower)) { case 'gaming': {
// GAMING: Session-duration based model // GAMING: Session-duration based model
// - Multiplayer games: 50-150 MB/hour (small packets, frequent) // - Multiplayer games: 50-150 MB/hour (small packets, frequent)
// - Average session: 2-3 hours // - Average session: 2-3 hours
// - Minecraft specifically uses more due to chunk loading // - Minecraft specifically uses more due to chunk loading
const isMinecraft = /minecraft|마인크래프트/.test(useCaseLower); const isMinecraft = /minecraft|마인크래프트/.test(useCaseLower);
const mbPerHour = isMinecraft ? 150 : 80; // Minecraft uses more const mbPerHour = isMinecraft ? 150 : 80; // Minecraft uses more
const avgSessionHours = isMinecraft ? 3 : 2.5; const avgSessionHours = isMinecraft ? 3 : 2.5;
const gbPerActiveUser = (mbPerHour * avgSessionHours) / 1024; const gbPerActiveUser = (mbPerHour * avgSessionHours) / 1024;
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
bandwidthModel = `session-based: ${activeDau} active × ${mbPerHour} MB/hr × ${avgSessionHours}hr`; bandwidthModel = `session-based: ${activeDau} active × ${mbPerHour} MB/hr × ${avgSessionHours}hr`;
break;
}
} else if (/api|saas|backend|서비스|백엔드/.test(useCaseLower)) { case 'api': {
// API/SAAS: Request-based model // API/SAAS: Request-based model
// - Average request+response: 10-50 KB // - Average request+response: 10-50 KB
// - Requests per active user per day: 500-2000 // - Requests per active user per day: 500-2000
const avgRequestKB = 20; const avgRequestKB = 20;
const requestsPerUserPerDay = 1000; const requestsPerUserPerDay = 1000;
const gbPerActiveUser = (avgRequestKB * requestsPerUserPerDay) / (1024 * 1024); const gbPerActiveUser = (avgRequestKB * requestsPerUserPerDay) / (1024 * 1024);
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
bandwidthModel = `request-based: ${activeDau} active × ${avgRequestKB}KB × ${requestsPerUserPerDay} req`; bandwidthModel = `request-based: ${activeDau} active × ${avgRequestKB}KB × ${requestsPerUserPerDay} req`;
break;
}
} else if (/e-?commerce|shop|store|쇼핑|커머스|온라인몰/.test(useCaseLower)) { case 'ecommerce': {
// E-COMMERCE: Page-based model (images heavy) // E-COMMERCE: Page-based model (images heavy)
// - Average page with images: 2-3 MB // - Average page with images: 2-3 MB
// - Pages per session: 15-25 (product browsing) // - Pages per session: 15-25 (product browsing)
const avgPageSizeMB = 2.5; const avgPageSizeMB = 2.5;
const pagesPerSession = 20; const pagesPerSession = 20;
const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024; const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024;
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
bandwidthModel = `page-based: ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`; bandwidthModel = `page-based: ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`;
break;
}
} else if (/forum|community|board|게시판|커뮤니티|포럼/.test(useCaseLower)) { case 'forum': {
// FORUM/COMMUNITY: Page-based model (text + some images) // FORUM/COMMUNITY: Page-based model (text + some images)
// - Average page: 0.5-1 MB // - Average page: 0.5-1 MB
// - Pages per session: 20-40 (thread reading) // - Pages per session: 20-40 (thread reading)
const avgPageSizeMB = 0.7; const avgPageSizeMB = 0.7;
const pagesPerSession = 30; const pagesPerSession = 30;
const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024; const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024;
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
bandwidthModel = `page-based: ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`; bandwidthModel = `page-based: ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`;
break;
}
} else if (/blog|static|portfolio|블로그|포트폴리오|landing/.test(useCaseLower)) { case 'blog': {
// STATIC/BLOG: Lightweight page-based model // STATIC/BLOG: Lightweight page-based model
// - Average page: 1-2 MB (optimized images) // - Average page: 1-2 MB (optimized images)
// - Pages per session: 3-5 (bounce rate high) // - Pages per session: 3-5 (bounce rate high)
const avgPageSizeMB = 1.5; const avgPageSizeMB = 1.5;
const pagesPerSession = 4; const pagesPerSession = 4;
const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024; const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024;
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
bandwidthModel = `page-based: ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`; bandwidthModel = `page-based: ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`;
break;
}
} else if (/chat|messaging|slack|discord|채팅|메신저/.test(useCaseLower)) { case 'chat': {
// CHAT/MESSAGING: Message-based model // CHAT/MESSAGING: Message-based model
// - Average message: 1-5 KB (text + small attachments) // - Average message: 1-5 KB (text + small attachments)
// - Messages per active user: 100-500 per day // - Messages per active user: 100-500 per day
// - Some image/file sharing: adds 10-50 MB/user // - Some image/file sharing: adds 10-50 MB/user
const textBandwidthMB = (3 * 200) / 1024; // 3KB × 200 messages const textBandwidthMB = (3 * 200) / 1024; // 3KB × 200 messages
const attachmentBandwidthMB = 20; // occasional images/files const attachmentBandwidthMB = 20; // occasional images/files
const gbPerActiveUser = (textBandwidthMB + attachmentBandwidthMB) / 1024; const gbPerActiveUser = (textBandwidthMB + attachmentBandwidthMB) / 1024;
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
bandwidthModel = `message-based: ${activeDau} active × ~20MB/user (text+attachments)`; bandwidthModel = `message-based: ${activeDau} active × ~20MB/user (text+attachments)`;
break;
}
} else { default: {
// DEFAULT: General web app (page-based) // DEFAULT: General web app (page-based)
const avgPageSizeMB = 1.0; const avgPageSizeMB = 1.0;
const pagesPerSession = 10; const pagesPerSession = 10;
const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024; const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024;
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
bandwidthModel = `page-based (default): ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`; bandwidthModel = `page-based (default): ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`;
break;
}
} }
console.log(`[Bandwidth] Model: ${bandwidthModel}`); console.log(`[Bandwidth] Model: ${bandwidthModel}`);
@@ -582,13 +639,21 @@ function getAllowedOrigin(request: Request): string {
'https://server-recommend.kappa-d8e.workers.dev', 'https://server-recommend.kappa-d8e.workers.dev',
]; ];
const origin = request.headers.get('Origin'); const origin = request.headers.get('Origin');
// If Origin is provided and matches allowed list, return it
if (origin && allowedOrigins.includes(origin)) { if (origin && allowedOrigins.includes(origin)) {
return origin; return origin;
} }
// Allow requests without Origin header (non-browser, curl, etc.)
// For requests without Origin (non-browser: curl, API clients, server-to-server)
// Return empty string - CORS headers won't be sent but request is still processed
// This is safe because CORS only affects browser requests
if (!origin) { if (!origin) {
return '*'; return '';
} }
// Origin provided but not in allowed list - return first allowed origin
// Browser will block the response due to CORS mismatch
return allowedOrigins[0]; return allowedOrigins[0];
} }
@@ -878,6 +943,16 @@ async function handleRecommend(
const requestId = crypto.randomUUID(); const requestId = crypto.randomUUID();
try { try {
// Check request body size to prevent large payload attacks
const contentLength = request.headers.get('Content-Length');
if (contentLength && parseInt(contentLength, 10) > 10240) { // 10KB limit
return jsonResponse(
{ error: 'Request body too large', max_size: '10KB' },
413,
corsHeaders
);
}
// Parse and validate request // Parse and validate request
const body = await request.json() as RecommendRequest; const body = await request.json() as RecommendRequest;
const lang = body.lang || 'en'; const lang = body.lang || 'en';
@@ -2204,7 +2279,13 @@ function parseAIResponse(response: any): AIRecommendationResponse {
* Sanitize special characters for cache key * Sanitize special characters for cache key
*/ */
function sanitizeCacheValue(value: string): string { function sanitizeCacheValue(value: string): string {
return value.replace(/[|:,]/g, '_'); // Use URL-safe base64 encoding to avoid collisions
try {
return btoa(value).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
} catch {
// Fallback for non-ASCII characters
return encodeURIComponent(value).replace(/[%]/g, '_');
}
} }
/** /**
@@ -2262,7 +2343,8 @@ function hashString(str: string): string {
hash = ((hash << 5) - hash) + char; hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32-bit integer hash = hash & hash; // Convert to 32-bit integer
} }
return Math.abs(hash).toString(36); // Use >>> 0 to convert to unsigned 32-bit integer
return (hash >>> 0).toString(36);
} }
/** /**
@@ -2277,6 +2359,7 @@ function jsonResponse(
status, status,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Content-Security-Policy': "default-src 'none'",
'X-Content-Type-Options': 'nosniff', 'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY', 'X-Frame-Options': 'DENY',
'Cache-Control': 'no-store', 'Cache-Control': 'no-store',