From a7fce17d5ba3744f4e8ffdfa6c0ee3beeaffb945 Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 15 Jul 2026 11:24:17 +0400 Subject: [PATCH] fix ssrf guard in image proxy (#111) - add domain allowlist for known image hosts (coverartarchive.org, lastfm.freetls.fastly.net, i.scdn.co, images.genius.com, etc.) - validate redirect targets against allowlist before following - parse URL properly instead of prefix check (prevents file:// bypass) - log blocked requests for visibility --- backend/src/routes/images.routes.ts | 71 +++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/backend/src/routes/images.routes.ts b/backend/src/routes/images.routes.ts index 1773de3..5021335 100644 --- a/backend/src/routes/images.routes.ts +++ b/backend/src/routes/images.routes.ts @@ -1,5 +1,28 @@ import { FastifyInstance } from 'fastify'; +// ── Domain allowlist ──────────────────────────────────────────────────────── +// Only these hosts may be proxied. Keeps the proxy from being used as an SSRF +// vector against internal services (metadata endpoints, cloud metadata, etc.). +const ALLOWED_HOSTS = new Set([ + 'coverartarchive.org', + 'lastfm.freetls.fastly.net', + 'i.scdn.co', + 'images.genius.com', + 'commons.wikimedia.org', + 'e.snmc.io', +]); + +// Wildcard suffixes — any subdomain of these is allowed. +const ALLOWED_SUFFIXES = [ + '.coverartarchive.org', + '.musicbrainz.org', +]; + +function isAllowed(hostname: string): boolean { + if (ALLOWED_HOSTS.has(hostname)) return true; + return ALLOWED_SUFFIXES.some(suffix => hostname.endsWith(suffix)); +} + /** * Image proxy — fetches external artwork URLs server-side and returns them * with aggressive caching headers so the browser never re-fetches from @@ -17,11 +40,59 @@ export default async function imagesRoutes(fastify: FastifyInstance) { return reply.code(400).send({ error: 'Only http/https URLs are supported' }); } + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return reply.code(400).send({ error: 'Invalid URL' }); + } + + if (!isAllowed(parsed.hostname)) { + request.log.warn({ hostname: parsed.hostname, url }, 'Image proxy blocked — host not in allowlist'); + return reply.code(403).send({ error: 'Domain not allowed' }); + } + try { const response = await fetch(url, { signal: AbortSignal.timeout(10_000), + // Follow up to 5 redirects but validate each hop's domain + redirect: 'manual', }); + // Handle redirect — validate the redirect target too + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get('location'); + if (!location) { + return reply.code(502).send({ error: 'Redirect with no location' }); + } + let redirectParsed: URL; + try { + redirectParsed = new URL(location); + } catch { + return reply.code(502).send({ error: 'Invalid redirect URL' }); + } + if (!isAllowed(redirectParsed.hostname)) { + request.log.warn({ hostname: redirectParsed.hostname, location }, 'Image proxy blocked — redirect target not in allowlist'); + return reply.code(403).send({ error: 'Redirect target not allowed' }); + } + // Re-fetch the redirect target + const redirectResponse = await fetch(location, { + signal: AbortSignal.timeout(10_000), + }); + if (!redirectResponse.ok) { + return reply.code(redirectResponse.status).send({ error: `Upstream returned ${redirectResponse.status}` }); + } + const redirectBuffer = await redirectResponse.arrayBuffer(); + const redirectContentType = redirectResponse.headers.get('content-type') || 'image/jpeg'; + return reply + .headers({ + 'Content-Type': redirectContentType, + 'Cache-Control': 'public, max-age=31536000, immutable', + 'Content-Length': redirectBuffer.byteLength, + }) + .send(Buffer.from(redirectBuffer)); + } + if (!response.ok) { return reply.code(response.status).send({ error: `Upstream returned ${response.status}` }); }