feat(Radio): - Redesigned Queue modal, added drag & drop capabilities - Added stream quality selector, currently offering: AAC @ 128kbps, AAC @ 320kbps & FLAC (lossless) fix(middleware): Import API_URL from config and remove hardcoded API_URL definition security(api): Enhance discord image and video caching with improved signature verification and error handling, updated image proxy to include production checks for signing secret
273 lines
7.4 KiB
TypeScript
273 lines
7.4 KiB
TypeScript
/**
|
|
* 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.ts';
|
|
|
|
// Secret key for signing URLs - MUST be set in production
|
|
const SIGNING_SECRET = import.meta.env.IMAGE_PROXY_SECRET;
|
|
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!');
|
|
}
|
|
|
|
// 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: string): boolean {
|
|
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: Record<string, string> = {
|
|
'.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: string): string | null {
|
|
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: string): Promise<string> {
|
|
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) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
console.error('[image-proxy] Error fetching image:', message);
|
|
return new Response('Failed to fetch image', { status: 500 });
|
|
}
|
|
}
|