2025-12-03 13:27:37 -05:00
|
|
|
/**
|
|
|
|
|
* 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,
|
2025-12-19 11:59:00 -05:00
|
|
|
} from '../../utils/rateLimit.ts';
|
2025-12-03 13:27:37 -05:00
|
|
|
|
2025-12-05 14:21:52 -05:00
|
|
|
// Secret key for signing URLs - MUST be set in production
|
|
|
|
|
const SIGNING_SECRET = import.meta.env.IMAGE_PROXY_SECRET;
|
2026-02-22 13:53:43 -05:00
|
|
|
if (!SIGNING_SECRET && import.meta.env.PROD) {
|
|
|
|
|
throw new Error('CRITICAL: IMAGE_PROXY_SECRET environment variable is not set in production!');
|
|
|
|
|
} else if (!SIGNING_SECRET) {
|
|
|
|
|
console.error('WARNING: IMAGE_PROXY_SECRET environment variable is not set!');
|
2025-12-05 14:21:52 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
];
|
|
|
|
|
|
2025-12-19 11:59:00 -05:00
|
|
|
function isPrivateUrl(urlString: string): boolean {
|
2025-12-05 14:21:52 -05:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-03 13:27:37 -05:00
|
|
|
|
|
|
|
|
// 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
|
2025-12-19 11:59:00 -05:00
|
|
|
const IMAGE_EXTENSIONS: Record<string, string> = {
|
2025-12-03 13:27:37 -05:00
|
|
|
'.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
|
|
|
|
|
*/
|
2025-12-19 11:59:00 -05:00
|
|
|
function getContentTypeFromUrl(url: string): string | null {
|
2025-12-03 13:27:37 -05:00
|
|
|
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
|
|
|
|
|
*/
|
2025-12-19 11:59:00 -05:00
|
|
|
export async function signImageUrl(imageUrl: string): Promise<string> {
|
2025-12-03 13:27:37 -05:00
|
|
|
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 });
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-05 14:21:52 -05:00
|
|
|
// Require signing secret to be configured
|
|
|
|
|
if (!SIGNING_SECRET) {
|
|
|
|
|
return new Response('Server misconfigured', { status: 500 });
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-03 13:27:37 -05:00
|
|
|
// 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 });
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-05 14:21:52 -05:00
|
|
|
// SSRF protection: block private/internal IPs
|
|
|
|
|
if (isPrivateUrl(imageUrl)) {
|
|
|
|
|
return new Response('URL not allowed', { status: 403 });
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-03 13:27:37 -05:00
|
|
|
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) {
|
2025-12-19 13:45:30 -05:00
|
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
|
|
|
console.error('[image-proxy] Error fetching image:', message);
|
2025-12-03 13:27:37 -05:00
|
|
|
return new Response('Failed to fetch image', { status: 500 });
|
|
|
|
|
}
|
|
|
|
|
}
|