Files
muzick/backend/src/routes/images.routes.ts
T
kami a7fce17d5b 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
2026-07-15 11:24:17 +04:00

121 lines
4.6 KiB
TypeScript

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
* Discogs / Cover Art Archive on repeat page loads.
*/
export default async function imagesRoutes(fastify: FastifyInstance) {
fastify.get('/images/proxy', async (request, reply) => {
const { url } = request.query as { url?: string };
if (!url) {
return reply.code(400).send({ error: 'url query parameter is required' });
}
// Only proxy http(s) URLs — don't be an open proxy for file:// etc.
if (!url.startsWith('http://') && !url.startsWith('https://')) {
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}` });
}
const buffer = await response.arrayBuffer();
const contentType = response.headers.get('content-type') || 'image/jpeg';
// Cache aggressively — artwork URLs are immutable (Discogs, Cover Art
// Archive etc. use content-addressed paths). 1 year.
return reply
.headers({
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000, immutable',
'Content-Length': buffer.byteLength,
})
.send(Buffer.from(buffer));
} catch (err: any) {
if (err?.name === 'TimeoutError' || err?.code === 'UND_ERR_CONNECT_TIMEOUT') {
return reply.code(504).send({ error: 'Upstream timed out' });
}
request.log.error({ err, url }, 'Image proxy failed');
return reply.code(502).send({ error: 'Failed to fetch image' });
}
});
}