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)
}
// 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',