From 0bb72966004aa9ed1f41f4d798ac066eb2f8c1eb Mon Sep 17 00:00:00 2001 From: kappa Date: Sun, 25 Jan 2026 16:29:45 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=EB=82=A8=EC=9D=80=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=ED=92=88=EC=A7=88/=EB=B3=B4=EC=95=88=20=EC=9D=B4?= =?UTF-8?q?=EC=8A=88=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/index.ts | 361 +++++++++++++++++++++++++++++++-------------------- 1 file changed, 222 insertions(+), 139 deletions(-) diff --git a/src/index.ts b/src/index.ts index 50af0be..69b948a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -149,64 +149,98 @@ interface BandwidthEstimate { 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) */ function getDauMultiplier(useCase: string): { min: number; max: number } { - const useCaseLower = useCase.toLowerCase(); - - 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 }; - } + return findUseCaseConfig(useCase).dauMultiplier; } /** * Get active user ratio (what percentage of DAU actually performs the bandwidth-heavy action) */ function getActiveUserRatio(useCase: string): number { - const useCaseLower = useCase.toLowerCase(); - - 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; - } + return findUseCaseConfig(useCase).activeRatio; } /** @@ -225,12 +259,16 @@ function getActiveUserRatio(useCase: string): number { function estimateBandwidth(concurrentUsers: number, useCase: string, trafficPattern?: string): BandwidthEstimate { 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 - const dauMultiplier = getDauMultiplier(useCase); + const dauMultiplier = config.dauMultiplier; const estimatedDauMin = Math.round(concurrentUsers * dauMultiplier.min); const estimatedDauMax = Math.round(concurrentUsers * dauMultiplier.max); const dailyUniqueVisitors = Math.round((estimatedDauMin + estimatedDauMax) / 2); - const activeUserRatio = getActiveUserRatio(useCase); + const activeUserRatio = config.activeRatio; const activeDau = Math.round(dailyUniqueVisitors * activeUserRatio); // Traffic pattern adjustment @@ -247,99 +285,118 @@ function estimateBandwidth(concurrentUsers: number, useCase: string, trafficPatt // ========== IMPROVED BANDWIDTH MODELS ========== // Each use case uses the most appropriate calculation method - if (/video|stream|media|youtube|netflix|vod|동영상|스트리밍|미디어/.test(useCaseLower)) { - // VIDEO/STREAMING: Bitrate-based model - // - HD streaming: ~5 Mbps = 2.25 GB/hour - // - Average watch time: 1.5 hours per session - // - 4K streaming: ~25 Mbps = 11.25 GB/hour (detect if mentioned) - const is4K = /4k|uhd|ultra/i.test(useCaseLower); - const bitrateGBperHour = is4K ? 11.25 : 2.25; // 4K vs HD - const avgWatchTimeHours = is4K ? 1.0 : 1.5; // 4K users watch less - const gbPerActiveUser = bitrateGBperHour * avgWatchTimeHours; - dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; - bandwidthModel = `bitrate-based: ${activeDau} active × ${bitrateGBperHour} GB/hr × ${avgWatchTimeHours}hr`; + switch (useCaseCategory) { + case 'video': { + // VIDEO/STREAMING: Bitrate-based model + // - HD streaming: ~5 Mbps = 2.25 GB/hour + // - Average watch time: 1.5 hours per session + // - 4K streaming: ~25 Mbps = 11.25 GB/hour (detect if mentioned) + const is4K = /4k|uhd|ultra/i.test(useCaseLower); + const bitrateGBperHour = is4K ? 11.25 : 2.25; // 4K vs HD + const avgWatchTimeHours = is4K ? 1.0 : 1.5; // 4K users watch less + const gbPerActiveUser = bitrateGBperHour * avgWatchTimeHours; + dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; + bandwidthModel = `bitrate-based: ${activeDau} active × ${bitrateGBperHour} GB/hr × ${avgWatchTimeHours}hr`; + break; + } - } else if (/download|file|storage|cdn|파일|다운로드|저장소/.test(useCaseLower)) { - // FILE DOWNLOAD: File-size based model - // - Average file size: 100-500 MB depending on type - // - Downloads per active user: 2-5 per day - const isLargeFiles = /iso|video|backup|대용량/.test(useCaseLower); - const avgFileSizeGB = isLargeFiles ? 2.0 : 0.2; // 2GB for large, 200MB for normal - const downloadsPerUser = isLargeFiles ? 1 : 3; - const gbPerActiveUser = avgFileSizeGB * downloadsPerUser; - dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; - bandwidthModel = `file-based: ${activeDau} active × ${avgFileSizeGB} GB × ${downloadsPerUser} downloads`; + case 'file': { + // FILE DOWNLOAD: File-size based model + // - Average file size: 100-500 MB depending on type + // - Downloads per active user: 2-5 per day + const isLargeFiles = /iso|video|backup|대용량/.test(useCaseLower); + const avgFileSizeGB = isLargeFiles ? 2.0 : 0.2; // 2GB for large, 200MB for normal + const downloadsPerUser = isLargeFiles ? 1 : 3; + const gbPerActiveUser = avgFileSizeGB * downloadsPerUser; + dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; + bandwidthModel = `file-based: ${activeDau} active × ${avgFileSizeGB} GB × ${downloadsPerUser} downloads`; + break; + } - } else if (/game|gaming|minecraft|게임/.test(useCaseLower)) { - // GAMING: Session-duration based model - // - Multiplayer games: 50-150 MB/hour (small packets, frequent) - // - Average session: 2-3 hours - // - Minecraft specifically uses more due to chunk loading - const isMinecraft = /minecraft|마인크래프트/.test(useCaseLower); - const mbPerHour = isMinecraft ? 150 : 80; // Minecraft uses more - const avgSessionHours = isMinecraft ? 3 : 2.5; - const gbPerActiveUser = (mbPerHour * avgSessionHours) / 1024; - dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; - bandwidthModel = `session-based: ${activeDau} active × ${mbPerHour} MB/hr × ${avgSessionHours}hr`; + case 'gaming': { + // GAMING: Session-duration based model + // - Multiplayer games: 50-150 MB/hour (small packets, frequent) + // - Average session: 2-3 hours + // - Minecraft specifically uses more due to chunk loading + const isMinecraft = /minecraft|마인크래프트/.test(useCaseLower); + const mbPerHour = isMinecraft ? 150 : 80; // Minecraft uses more + const avgSessionHours = isMinecraft ? 3 : 2.5; + const gbPerActiveUser = (mbPerHour * avgSessionHours) / 1024; + dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; + bandwidthModel = `session-based: ${activeDau} active × ${mbPerHour} MB/hr × ${avgSessionHours}hr`; + break; + } - } else if (/api|saas|backend|서비스|백엔드/.test(useCaseLower)) { - // API/SAAS: Request-based model - // - Average request+response: 10-50 KB - // - Requests per active user per day: 500-2000 - const avgRequestKB = 20; - const requestsPerUserPerDay = 1000; - const gbPerActiveUser = (avgRequestKB * requestsPerUserPerDay) / (1024 * 1024); - dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; - bandwidthModel = `request-based: ${activeDau} active × ${avgRequestKB}KB × ${requestsPerUserPerDay} req`; + case 'api': { + // API/SAAS: Request-based model + // - Average request+response: 10-50 KB + // - Requests per active user per day: 500-2000 + const avgRequestKB = 20; + const requestsPerUserPerDay = 1000; + const gbPerActiveUser = (avgRequestKB * requestsPerUserPerDay) / (1024 * 1024); + dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; + bandwidthModel = `request-based: ${activeDau} active × ${avgRequestKB}KB × ${requestsPerUserPerDay} req`; + break; + } - } else if (/e-?commerce|shop|store|쇼핑|커머스|온라인몰/.test(useCaseLower)) { - // E-COMMERCE: Page-based model (images heavy) - // - Average page with images: 2-3 MB - // - Pages per session: 15-25 (product browsing) - const avgPageSizeMB = 2.5; - const pagesPerSession = 20; - const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024; - dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; - bandwidthModel = `page-based: ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`; + case 'ecommerce': { + // E-COMMERCE: Page-based model (images heavy) + // - Average page with images: 2-3 MB + // - Pages per session: 15-25 (product browsing) + const avgPageSizeMB = 2.5; + const pagesPerSession = 20; + const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024; + dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; + bandwidthModel = `page-based: ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`; + break; + } - } else if (/forum|community|board|게시판|커뮤니티|포럼/.test(useCaseLower)) { - // FORUM/COMMUNITY: Page-based model (text + some images) - // - Average page: 0.5-1 MB - // - Pages per session: 20-40 (thread reading) - const avgPageSizeMB = 0.7; - const pagesPerSession = 30; - const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024; - dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; - bandwidthModel = `page-based: ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`; + case 'forum': { + // FORUM/COMMUNITY: Page-based model (text + some images) + // - Average page: 0.5-1 MB + // - Pages per session: 20-40 (thread reading) + const avgPageSizeMB = 0.7; + const pagesPerSession = 30; + const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024; + dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; + bandwidthModel = `page-based: ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`; + break; + } - } else if (/blog|static|portfolio|블로그|포트폴리오|landing/.test(useCaseLower)) { - // STATIC/BLOG: Lightweight page-based model - // - Average page: 1-2 MB (optimized images) - // - Pages per session: 3-5 (bounce rate high) - const avgPageSizeMB = 1.5; - const pagesPerSession = 4; - const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024; - dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; - bandwidthModel = `page-based: ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`; + case 'blog': { + // STATIC/BLOG: Lightweight page-based model + // - Average page: 1-2 MB (optimized images) + // - Pages per session: 3-5 (bounce rate high) + const avgPageSizeMB = 1.5; + const pagesPerSession = 4; + const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024; + dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; + bandwidthModel = `page-based: ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`; + break; + } - } else if (/chat|messaging|slack|discord|채팅|메신저/.test(useCaseLower)) { - // CHAT/MESSAGING: Message-based model - // - Average message: 1-5 KB (text + small attachments) - // - Messages per active user: 100-500 per day - // - Some image/file sharing: adds 10-50 MB/user - const textBandwidthMB = (3 * 200) / 1024; // 3KB × 200 messages - const attachmentBandwidthMB = 20; // occasional images/files - const gbPerActiveUser = (textBandwidthMB + attachmentBandwidthMB) / 1024; - dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; - bandwidthModel = `message-based: ${activeDau} active × ~20MB/user (text+attachments)`; + case 'chat': { + // CHAT/MESSAGING: Message-based model + // - Average message: 1-5 KB (text + small attachments) + // - Messages per active user: 100-500 per day + // - Some image/file sharing: adds 10-50 MB/user + const textBandwidthMB = (3 * 200) / 1024; // 3KB × 200 messages + const attachmentBandwidthMB = 20; // occasional images/files + const gbPerActiveUser = (textBandwidthMB + attachmentBandwidthMB) / 1024; + dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; + bandwidthModel = `message-based: ${activeDau} active × ~20MB/user (text+attachments)`; + break; + } - } else { - // DEFAULT: General web app (page-based) - const avgPageSizeMB = 1.0; - const pagesPerSession = 10; - const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024; - dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; - bandwidthModel = `page-based (default): ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`; + default: { + // DEFAULT: General web app (page-based) + const avgPageSizeMB = 1.0; + const pagesPerSession = 10; + const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024; + dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; + bandwidthModel = `page-based (default): ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`; + break; + } } console.log(`[Bandwidth] Model: ${bandwidthModel}`); @@ -582,13 +639,21 @@ function getAllowedOrigin(request: Request): string { 'https://server-recommend.kappa-d8e.workers.dev', ]; const origin = request.headers.get('Origin'); + + // If Origin is provided and matches allowed list, return it if (origin && allowedOrigins.includes(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) { - 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]; } @@ -878,6 +943,16 @@ async function handleRecommend( const requestId = crypto.randomUUID(); 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 const body = await request.json() as RecommendRequest; const lang = body.lang || 'en'; @@ -2204,7 +2279,13 @@ function parseAIResponse(response: any): AIRecommendationResponse { * Sanitize special characters for cache key */ 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 & 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, headers: { 'Content-Type': 'application/json', + 'Content-Security-Policy': "default-src 'none'", 'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'DENY', 'Cache-Control': 'no-store',