Files
cloud-orchestrator/src/utils/cache.ts
kappa 5319bf3e4c refactor: comprehensive code review fixes and security hardening
Security:
- Add CSP headers for HTML reports (style-src 'unsafe-inline')
- Restrict origin validation to specific .kappa-d8e.workers.dev domain
- Add base64 size limit (100KB) for report data parameter
- Implement rejection sampling for unbiased password generation
- Add SQL LIKE pattern escaping for tech specs query
- Add security warning for plaintext password storage (TODO: encrypt)

Performance:
- Add Telegram API timeout (10s) with AbortController
- Fix rate limiter sorting by resetTime for proper cleanup
- Use centralized TIMEOUTS config for VPS provider APIs

Features:
- Add admin SSH key support for server recovery access
  - ADMIN_SSH_PUBLIC_KEY for Linode (public key string)
  - ADMIN_SSH_KEY_ID_VULTR for Vultr (pre-registered key ID)
- Add origin validation middleware
- Add idempotency key migration

Code Quality:
- Return 404 status when no servers found
- Consolidate error logging to single JSON.stringify call
- Import TECH_CATEGORY_WEIGHTS from config.ts
- Add escapeLikePattern utility function

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 11:36:08 +09:00

173 lines
5.0 KiB
TypeScript

/**
* Cache and rate limiting utility functions
*/
import type { RecommendRequest, Env } from '../types';
import { LIMITS } from '../config';
/**
* Simple hash function for strings
*/
export function hashString(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32-bit integer
}
// Use >>> 0 to convert to unsigned 32-bit integer
return (hash >>> 0).toString(36);
}
/**
* Sanitize special characters for cache key
*/
export function sanitizeCacheValue(value: string): string {
// 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, '_');
}
}
/**
* Generate cache key from request parameters
*/
export function generateCacheKey(req: RecommendRequest): string {
// Don't mutate original arrays - create sorted copies
const sortedStack = [...req.tech_stack].sort();
const sanitizedStack = sortedStack.map(sanitizeCacheValue).join(',');
// Hash use_case to avoid special characters and length issues
const useCaseHash = hashString(req.use_case);
const parts = [
`stack:${sanitizedStack}`,
`users:${req.expected_users}`,
`case:${useCaseHash}`,
];
if (req.traffic_pattern) {
parts.push(`traffic:${req.traffic_pattern}`);
}
if (req.budget_limit) {
parts.push(`budget:${req.budget_limit}`);
}
// Include region preference in cache key
if (req.region_preference && req.region_preference.length > 0) {
const sortedRegions = [...req.region_preference].sort();
const sanitizedRegions = sortedRegions.map(sanitizeCacheValue).join(',');
parts.push(`region:${sanitizedRegions}`);
}
// Include language in cache key
if (req.lang) {
parts.push(`lang:${req.lang}`);
}
// Include CDN options in cache key
if (req.cdn_enabled !== undefined) {
parts.push(`cdn:${req.cdn_enabled}`);
}
if (req.cdn_cache_hit_rate !== undefined) {
parts.push(`cdnrate:${req.cdn_cache_hit_rate}`);
}
return `recommend:${parts.join('|')}`;
}
// In-memory fallback for rate limiting when CACHE KV is unavailable
const inMemoryRateLimit = new Map<string, { count: number; resetTime: number }>();
const MAX_IN_MEMORY_ENTRIES = 10000;
/**
* Clean up expired entries from in-memory rate limit map
*/
function cleanupExpiredEntries(now: number): void {
for (const [key, record] of inMemoryRateLimit) {
if (record.resetTime < now) {
inMemoryRateLimit.delete(key);
}
}
}
/**
* Rate limiting check using KV storage with in-memory fallback
*/
export async function checkRateLimit(clientIP: string, env: Env): Promise<{ allowed: boolean; requestId: string }> {
const requestId = crypto.randomUUID();
const now = Date.now();
const maxRequests = LIMITS.RATE_LIMIT_MAX_REQUESTS;
const windowMs = LIMITS.RATE_LIMIT_WINDOW_MS;
// Use in-memory fallback if CACHE unavailable
if (!env.CACHE) {
// Cleanup expired entries if map is getting too large
if (inMemoryRateLimit.size >= MAX_IN_MEMORY_ENTRIES) {
cleanupExpiredEntries(now);
// If still too large after cleanup, remove oldest 10% by resetTime
if (inMemoryRateLimit.size >= MAX_IN_MEMORY_ENTRIES) {
const entries = Array.from(inMemoryRateLimit.entries())
.sort((a, b) => a[1].resetTime - b[1].resetTime)
.slice(0, Math.floor(MAX_IN_MEMORY_ENTRIES * 0.1));
entries.forEach(([key]) => inMemoryRateLimit.delete(key));
}
}
const record = inMemoryRateLimit.get(clientIP);
if (!record || record.resetTime < now) {
// New window or expired
inMemoryRateLimit.set(clientIP, { count: 1, resetTime: now + windowMs });
return { allowed: true, requestId };
}
if (record.count >= maxRequests) {
return { allowed: false, requestId };
}
// Increment count
record.count++;
return { allowed: true, requestId };
}
// KV-based rate limiting
const kvKey = `ratelimit:${clientIP}`;
try {
const recordJson = await env.CACHE.get(kvKey);
const record = recordJson ? JSON.parse(recordJson) as { count: number; resetTime: number } : null;
if (!record || record.resetTime < now) {
// New window
await env.CACHE.put(
kvKey,
JSON.stringify({ count: 1, resetTime: now + windowMs }),
{ expirationTtl: 60 }
);
return { allowed: true, requestId };
}
if (record.count >= maxRequests) {
return { allowed: false, requestId };
}
// Increment count
record.count++;
await env.CACHE.put(
kvKey,
JSON.stringify(record),
{ expirationTtl: 60 }
);
return { allowed: true, requestId };
} catch (error) {
console.error('[RateLimit] KV error:', error);
// On error, deny the request (fail closed) for security
return { allowed: false, requestId };
}
}