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>
173 lines
5.0 KiB
TypeScript
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 };
|
|
}
|
|
}
|