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:
209
src/index.ts
209
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,7 +285,8 @@ 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)) {
|
||||
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
|
||||
@@ -258,8 +297,10 @@ function estimateBandwidth(concurrentUsers: number, useCase: string, trafficPatt
|
||||
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)) {
|
||||
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
|
||||
@@ -269,8 +310,10 @@ function estimateBandwidth(concurrentUsers: number, useCase: string, trafficPatt
|
||||
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)) {
|
||||
case 'gaming': {
|
||||
// GAMING: Session-duration based model
|
||||
// - Multiplayer games: 50-150 MB/hour (small packets, frequent)
|
||||
// - Average session: 2-3 hours
|
||||
@@ -281,8 +324,10 @@ function estimateBandwidth(concurrentUsers: number, useCase: string, trafficPatt
|
||||
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)) {
|
||||
case 'api': {
|
||||
// API/SAAS: Request-based model
|
||||
// - Average request+response: 10-50 KB
|
||||
// - Requests per active user per day: 500-2000
|
||||
@@ -291,8 +336,10 @@ function estimateBandwidth(concurrentUsers: number, useCase: string, trafficPatt
|
||||
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)) {
|
||||
case 'ecommerce': {
|
||||
// E-COMMERCE: Page-based model (images heavy)
|
||||
// - Average page with images: 2-3 MB
|
||||
// - Pages per session: 15-25 (product browsing)
|
||||
@@ -301,8 +348,10 @@ function estimateBandwidth(concurrentUsers: number, useCase: string, trafficPatt
|
||||
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)) {
|
||||
case 'forum': {
|
||||
// FORUM/COMMUNITY: Page-based model (text + some images)
|
||||
// - Average page: 0.5-1 MB
|
||||
// - Pages per session: 20-40 (thread reading)
|
||||
@@ -311,8 +360,10 @@ function estimateBandwidth(concurrentUsers: number, useCase: string, trafficPatt
|
||||
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)) {
|
||||
case 'blog': {
|
||||
// STATIC/BLOG: Lightweight page-based model
|
||||
// - Average page: 1-2 MB (optimized images)
|
||||
// - Pages per session: 3-5 (bounce rate high)
|
||||
@@ -321,8 +372,10 @@ function estimateBandwidth(concurrentUsers: number, useCase: string, trafficPatt
|
||||
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)) {
|
||||
case 'chat': {
|
||||
// CHAT/MESSAGING: Message-based model
|
||||
// - Average message: 1-5 KB (text + small attachments)
|
||||
// - Messages per active user: 100-500 per day
|
||||
@@ -332,14 +385,18 @@ function estimateBandwidth(concurrentUsers: number, useCase: string, trafficPatt
|
||||
const gbPerActiveUser = (textBandwidthMB + attachmentBandwidthMB) / 1024;
|
||||
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
|
||||
bandwidthModel = `message-based: ${activeDau} active × ~20MB/user (text+attachments)`;
|
||||
break;
|
||||
}
|
||||
|
||||
} else {
|
||||
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',
|
||||
|
||||
Reference in New Issue
Block a user