/** * Server-side image proxy API endpoint * Proxies images from untrusted domains to prevent user IP exposure * Uses HMAC signatures to prevent abuse */ import { checkRateLimit, recordRequest, getCookieId, generateNonce, createNonceCookie, } from '../../utils/rateLimit.js'; // Secret key for signing URLs - MUST be set in production const SIGNING_SECRET = import.meta.env.IMAGE_PROXY_SECRET; if (!SIGNING_SECRET) { console.error('CRITICAL: IMAGE_PROXY_SECRET environment variable is not set!'); } // Private IP ranges to block (SSRF protection) const PRIVATE_IP_PATTERNS = [ /^127\./, // localhost /^10\./, // Class A private /^172\.(1[6-9]|2[0-9]|3[0-1])\./, // Class B private /^192\.168\./, // Class C private /^169\.254\./, // Link-local /^0\./, // Current network /^224\./, // Multicast /^255\./, // Broadcast /^localhost$/i, /^\[?::1\]?$/, // IPv6 localhost /^\[?fe80:/i, // IPv6 link-local /^\[?fc00:/i, // IPv6 private /^\[?fd00:/i, // IPv6 private ]; function isPrivateUrl(urlString) { try { const url = new URL(urlString); const hostname = url.hostname; return PRIVATE_IP_PATTERNS.some(pattern => pattern.test(hostname)); } catch { return true; // Block invalid URLs } } // Max image size to proxy (25MB - needed for animated GIFs) const MAX_IMAGE_SIZE = 25 * 1024 * 1024; // Allowed content types const ALLOWED_CONTENT_TYPES = [ 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml', 'image/bmp', 'image/x-icon', 'image/vnd.microsoft.icon', 'image/avif', 'image/apng', ]; // Image extensions for fallback content-type detection const IMAGE_EXTENSIONS = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.bmp': 'image/bmp', '.ico': 'image/x-icon', '.avif': 'image/avif', '.apng': 'image/apng', }; /** * Get content type from URL extension */ function getContentTypeFromUrl(url) { try { const pathname = new URL(url).pathname.toLowerCase(); for (const [ext, type] of Object.entries(IMAGE_EXTENSIONS)) { if (pathname.endsWith(ext)) { return type; } } } catch {} return null; } /** * Generate HMAC signature for a URL */ export async function signImageUrl(imageUrl) { const encoder = new TextEncoder(); const key = await crypto.subtle.importKey( 'raw', encoder.encode(SIGNING_SECRET), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] ); const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(imageUrl)); const signatureHex = Array.from(new Uint8Array(signature)) .map(b => b.toString(16).padStart(2, '0')) .join(''); return signatureHex; } /** * Verify HMAC signature for a URL */ async function verifySignature(imageUrl, signature) { const expectedSignature = await signImageUrl(imageUrl); return signature === expectedSignature; } /** * Create a signed proxy URL for an image */ export async function createSignedProxyUrl(imageUrl) { const signature = await signImageUrl(imageUrl); return `/api/image-proxy?url=${encodeURIComponent(imageUrl)}&sig=${signature}`; } export async function GET({ request }) { // Rate limit check const rateCheck = checkRateLimit(request, { limit: 20, windowMs: 1000, burstLimit: 100, burstWindowMs: 10_000, }); let cookieId = getCookieId(request); const hadCookie = !!cookieId; if (!cookieId) { cookieId = generateNonce(); } if (!rateCheck.allowed) { const response = new Response('Rate limit exceeded', { status: 429, headers: { 'Retry-After': '1' }, }); if (!hadCookie) { response.headers.set('Set-Cookie', createNonceCookie(cookieId)); } return response; } recordRequest(request, 1000); const url = new URL(request.url); const imageUrl = url.searchParams.get('url'); const signature = url.searchParams.get('sig'); if (!imageUrl) { return new Response('Missing url parameter', { status: 400 }); } if (!signature) { return new Response('Missing signature', { status: 403 }); } // Require signing secret to be configured if (!SIGNING_SECRET) { return new Response('Server misconfigured', { status: 500 }); } // Verify the signature const isValid = await verifySignature(imageUrl, signature); if (!isValid) { return new Response('Invalid signature', { status: 403 }); } // Validate URL format let parsedUrl; try { parsedUrl = new URL(imageUrl); if (!['http:', 'https:'].includes(parsedUrl.protocol)) { throw new Error('Invalid protocol'); } } catch { return new Response('Invalid URL', { status: 400 }); } // SSRF protection: block private/internal IPs if (isPrivateUrl(imageUrl)) { return new Response('URL not allowed', { status: 403 }); } try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10000); const response = await fetch(imageUrl, { method: 'GET', headers: { 'User-Agent': 'Mozilla/5.0 (compatible; ImageProxy/1.0)', 'Accept': 'image/*', }, signal: controller.signal, redirect: 'follow', }); clearTimeout(timeout); if (!response.ok) { return new Response('Failed to fetch image', { status: 502 }); } let contentType = response.headers.get('content-type') || ''; const contentLength = parseInt(response.headers.get('content-length') || '0', 10); // Validate content type - must be an image let isAllowedType = ALLOWED_CONTENT_TYPES.some(type => contentType.startsWith(type)); // If server returned generic binary type, try to infer from URL extension if (!isAllowedType && (contentType.includes('octet-stream') || contentType === '')) { const inferredType = getContentTypeFromUrl(imageUrl); if (inferredType) { contentType = inferredType; isAllowedType = true; } } if (!isAllowedType) { console.error(`[image-proxy] Invalid content type: ${contentType} for URL: ${imageUrl}`); return new Response(`Invalid content type: ${contentType}`, { status: 400 }); } // Check size limit if (contentLength > MAX_IMAGE_SIZE) { return new Response('Image too large', { status: 413 }); } // Stream the image through const imageData = await response.arrayBuffer(); // Double-check size after download if (imageData.byteLength > MAX_IMAGE_SIZE) { return new Response('Image too large', { status: 413 }); } const proxyResponse = new Response(imageData, { status: 200, headers: { 'Content-Type': contentType, 'Content-Length': imageData.byteLength.toString(), 'Cache-Control': 'public, max-age=86400', // Cache for 1 day 'X-Content-Type-Options': 'nosniff', }, }); if (!hadCookie) { proxyResponse.headers.set('Set-Cookie', createNonceCookie(cookieId)); } return proxyResponse; } catch (err) { console.error('[image-proxy] Error fetching image:', err.message); return new Response('Failed to fetch image', { status: 500 }); } }