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
This commit is contained in:
@@ -1,5 +1,28 @@
|
|||||||
import { FastifyInstance } from 'fastify';
|
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
|
* Image proxy — fetches external artwork URLs server-side and returns them
|
||||||
* with aggressive caching headers so the browser never re-fetches from
|
* 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' });
|
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 {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
signal: AbortSignal.timeout(10_000),
|
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) {
|
if (!response.ok) {
|
||||||
return reply.code(response.status).send({ error: `Upstream returned ${response.status}` });
|
return reply.code(response.status).send({ error: `Upstream returned ${response.status}` });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user