Spaces:
Build error
Build error
| import type { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/cloudflare'; | |
| // Rate limiting store (in-memory for serverless environments) | |
| const rateLimitStore = new Map<string, { count: number; resetTime: number }>(); | |
| // Rate limit configuration | |
| const RATE_LIMITS = { | |
| // General API endpoints | |
| '/api/*': { windowMs: 15 * 60 * 1000, maxRequests: 100 }, // 100 requests per 15 minutes | |
| // LLM API (more restrictive) | |
| '/api/llmcall': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 requests per minute | |
| // GitHub API endpoints | |
| '/api/github-*': { windowMs: 60 * 1000, maxRequests: 30 }, // 30 requests per minute | |
| // Netlify API endpoints | |
| '/api/netlify-*': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 requests per minute | |
| }; | |
| /** | |
| * Rate limiting middleware | |
| */ | |
| export function checkRateLimit(request: Request, endpoint: string): { allowed: boolean; resetTime?: number } { | |
| const clientIP = getClientIP(request); | |
| const key = `${clientIP}:${endpoint}`; | |
| // Find matching rate limit rule | |
| const rule = Object.entries(RATE_LIMITS).find(([pattern]) => { | |
| if (pattern.endsWith('/*')) { | |
| const basePattern = pattern.slice(0, -2); | |
| return endpoint.startsWith(basePattern); | |
| } | |
| return endpoint === pattern; | |
| }); | |
| if (!rule) { | |
| return { allowed: true }; // No rate limit for this endpoint | |
| } | |
| const [, config] = rule; | |
| const now = Date.now(); | |
| const windowStart = now - config.windowMs; | |
| // Clean up old entries | |
| for (const [storedKey, data] of rateLimitStore.entries()) { | |
| if (data.resetTime < windowStart) { | |
| rateLimitStore.delete(storedKey); | |
| } | |
| } | |
| // Get or create rate limit data | |
| const rateLimitData = rateLimitStore.get(key) || { count: 0, resetTime: now + config.windowMs }; | |
| if (rateLimitData.count >= config.maxRequests) { | |
| return { allowed: false, resetTime: rateLimitData.resetTime }; | |
| } | |
| // Update rate limit data | |
| rateLimitData.count++; | |
| rateLimitStore.set(key, rateLimitData); | |
| return { allowed: true }; | |
| } | |
| /** | |
| * Get client IP address from request | |
| */ | |
| function getClientIP(request: Request): string { | |
| // Try various headers that might contain the real IP | |
| const forwardedFor = request.headers.get('x-forwarded-for'); | |
| const realIP = request.headers.get('x-real-ip'); | |
| const cfConnectingIP = request.headers.get('cf-connecting-ip'); | |
| // Return the first available IP or a fallback | |
| return cfConnectingIP || realIP || forwardedFor?.split(',')[0]?.trim() || 'unknown'; | |
| } | |
| /** | |
| * Security headers middleware | |
| */ | |
| export function createSecurityHeaders() { | |
| return { | |
| // Prevent clickjacking | |
| 'X-Frame-Options': 'DENY', | |
| // Prevent MIME type sniffing | |
| 'X-Content-Type-Options': 'nosniff', | |
| // Enable XSS protection | |
| 'X-XSS-Protection': '1; mode=block', | |
| // Content Security Policy - restrict to same origin and trusted sources | |
| 'Content-Security-Policy': [ | |
| "default-src 'self'", | |
| "script-src 'self' 'unsafe-inline' 'unsafe-eval'", // Allow inline scripts for React | |
| "style-src 'self' 'unsafe-inline'", // Allow inline styles | |
| "img-src 'self' data: https: blob:", // Allow images from same origin, data URLs, and HTTPS | |
| "font-src 'self' data:", // Allow fonts from same origin and data URLs | |
| "connect-src 'self' https://api.github.com https://api.netlify.com", // Allow connections to GitHub and Netlify APIs | |
| "frame-src 'none'", // Prevent iframe embedding | |
| "object-src 'none'", // Prevent object embedding | |
| "base-uri 'self'", | |
| "form-action 'self'", | |
| ].join('; '), | |
| // Referrer Policy | |
| 'Referrer-Policy': 'strict-origin-when-cross-origin', | |
| // Permissions Policy (formerly Feature Policy) | |
| 'Permissions-Policy': ['camera=()', 'microphone=()', 'geolocation=()', 'payment=()'].join(', '), | |
| // HSTS (HTTP Strict Transport Security) - only in production | |
| ...(process.env.NODE_ENV === 'production' | |
| ? { | |
| 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains; preload', | |
| } | |
| : {}), | |
| }; | |
| } | |
| /** | |
| * Validate API key format (basic validation) | |
| */ | |
| export function validateApiKeyFormat(apiKey: string, provider: string): boolean { | |
| if (!apiKey || typeof apiKey !== 'string') { | |
| return false; | |
| } | |
| // Basic length checks for different providers | |
| const minLengths: Record<string, number> = { | |
| anthropic: 50, | |
| openai: 50, | |
| groq: 50, | |
| google: 30, | |
| github: 30, | |
| netlify: 30, | |
| }; | |
| const minLength = minLengths[provider.toLowerCase()] || 20; | |
| return apiKey.length >= minLength && !apiKey.includes('your_') && !apiKey.includes('here'); | |
| } | |
| /** | |
| * Sanitize error messages to prevent information leakage | |
| */ | |
| export function sanitizeErrorMessage(error: unknown, isDevelopment = false): string { | |
| if (isDevelopment) { | |
| // In development, show full error details | |
| return error instanceof Error ? error.message : String(error); | |
| } | |
| // In production, show generic messages to prevent information leakage | |
| if (error instanceof Error) { | |
| // Check for sensitive information in error messages | |
| if (error.message.includes('API key') || error.message.includes('token') || error.message.includes('secret')) { | |
| return 'Authentication failed'; | |
| } | |
| if (error.message.includes('rate limit') || error.message.includes('429')) { | |
| return 'Rate limit exceeded. Please try again later.'; | |
| } | |
| } | |
| return 'An unexpected error occurred'; | |
| } | |
| /** | |
| * Security wrapper for API routes | |
| */ | |
| export function withSecurity<T extends (args: ActionFunctionArgs | LoaderFunctionArgs) => Promise<Response>>( | |
| handler: T, | |
| options: { | |
| requireAuth?: boolean; | |
| rateLimit?: boolean; | |
| allowedMethods?: string[]; | |
| } = {}, | |
| ) { | |
| return async (args: ActionFunctionArgs | LoaderFunctionArgs): Promise<Response> => { | |
| const { request } = args; | |
| const url = new URL(request.url); | |
| const endpoint = url.pathname; | |
| // Check allowed methods | |
| if (options.allowedMethods && !options.allowedMethods.includes(request.method)) { | |
| return new Response('Method not allowed', { | |
| status: 405, | |
| headers: createSecurityHeaders(), | |
| }); | |
| } | |
| // Apply rate limiting | |
| if (options.rateLimit !== false) { | |
| const rateLimitResult = checkRateLimit(request, endpoint); | |
| if (!rateLimitResult.allowed) { | |
| return new Response('Rate limit exceeded', { | |
| status: 429, | |
| headers: { | |
| ...createSecurityHeaders(), | |
| 'Retry-After': Math.ceil((rateLimitResult.resetTime! - Date.now()) / 1000).toString(), | |
| 'X-RateLimit-Reset': rateLimitResult.resetTime!.toString(), | |
| }, | |
| }); | |
| } | |
| } | |
| try { | |
| // Execute the handler | |
| const response = await handler(args); | |
| // Add security headers to response | |
| const responseHeaders = new Headers(response.headers); | |
| Object.entries(createSecurityHeaders()).forEach(([key, value]) => { | |
| responseHeaders.set(key, value); | |
| }); | |
| return new Response(response.body, { | |
| status: response.status, | |
| statusText: response.statusText, | |
| headers: responseHeaders, | |
| }); | |
| } catch (error) { | |
| console.error('Security-wrapped handler error:', error); | |
| const errorMessage = sanitizeErrorMessage(error, process.env.NODE_ENV === 'development'); | |
| return new Response( | |
| JSON.stringify({ | |
| error: true, | |
| message: errorMessage, | |
| }), | |
| { | |
| status: 500, | |
| headers: { | |
| ...createSecurityHeaders(), | |
| 'Content-Type': 'application/json', | |
| }, | |
| }, | |
| ); | |
| } | |
| }; | |
| } | |