|
| 1 | +import { Agent, buildConnector, fetch as undiciFetch } from 'undici'; |
| 2 | + |
| 3 | +const REQUEST_TIMEOUT_MS = 15000; |
| 4 | + |
| 5 | +/** |
| 6 | + * Hosts the image proxy (`/images?src=…`) is allowed to fetch from, read from |
| 7 | + * the `IMAGE_ALLOWED_HOSTS` environment variable (comma-separated). This is a |
| 8 | + * strict allowlist: any host not listed — public or private — is refused. It |
| 9 | + * both blocks SSRF (loopback, link-local metadata, private ranges, …) and stops |
| 10 | + * the endpoint from being abused as a free image-optimization proxy for |
| 11 | + * arbitrary third-party images. When unset/empty, no external host is allowed |
| 12 | + * (only local media/public/theme images are processed). |
| 13 | + */ |
| 14 | +export function getAllowedImageHosts(): string[] { |
| 15 | + return (process.env.IMAGE_ALLOWED_HOSTS || '') |
| 16 | + .split(',') |
| 17 | + .map((host) => host.trim().toLowerCase()) |
| 18 | + .filter(Boolean); |
| 19 | +} |
| 20 | + |
| 21 | +function isAllowedHost(host: string | undefined | null): boolean { |
| 22 | + if (!host) { |
| 23 | + return false; |
| 24 | + } |
| 25 | + return getAllowedImageHosts().includes(host.toLowerCase()); |
| 26 | +} |
| 27 | + |
| 28 | +const baseConnector = buildConnector({}); |
| 29 | + |
| 30 | +type Connector = ReturnType<typeof buildConnector>; |
| 31 | +type ConnectorOptions = Parameters<Connector>[0]; |
| 32 | +type ConnectorCallback = Parameters<Connector>[1]; |
| 33 | + |
| 34 | +/** |
| 35 | + * Refuse to open a connection to any host that is not on the allowlist. Because |
| 36 | + * this runs for every connection — including each redirect hop — a redirect |
| 37 | + * from an allowed host to a non-allowed one (e.g. an open redirect pointed at an |
| 38 | + * internal address) is still blocked. |
| 39 | + */ |
| 40 | +const guardedConnector = ( |
| 41 | + options: ConnectorOptions, |
| 42 | + callback: ConnectorCallback |
| 43 | +): void => { |
| 44 | + if (!isAllowedHost(options.hostname)) { |
| 45 | + callback( |
| 46 | + new Error( |
| 47 | + `Blocked image fetch from non-allowlisted host: ${options.hostname}` |
| 48 | + ), |
| 49 | + null |
| 50 | + ); |
| 51 | + return; |
| 52 | + } |
| 53 | + baseConnector(options, callback); |
| 54 | +}; |
| 55 | + |
| 56 | +const guardedAgent = new Agent({ connect: guardedConnector }); |
| 57 | + |
| 58 | +/** |
| 59 | + * Parse and validate an untrusted image URL: http/https only, and the host must |
| 60 | + * be on the `IMAGE_ALLOWED_HOSTS` allowlist. Returns the parsed URL. |
| 61 | + */ |
| 62 | +export function assertAllowedUrl(rawUrl: string): URL { |
| 63 | + let url: URL; |
| 64 | + try { |
| 65 | + url = new URL(rawUrl); |
| 66 | + } catch { |
| 67 | + throw new Error('Invalid URL'); |
| 68 | + } |
| 69 | + if (url.protocol !== 'http:' && url.protocol !== 'https:') { |
| 70 | + throw new Error('Only http and https URLs are allowed'); |
| 71 | + } |
| 72 | + const host = url.hostname.replace(/^\[/, '').replace(/\]$/, ''); |
| 73 | + if (!isAllowedHost(host)) { |
| 74 | + throw new Error( |
| 75 | + `Image host "${host}" is not allowed. Add it to the IMAGE_ALLOWED_HOSTS environment variable.` |
| 76 | + ); |
| 77 | + } |
| 78 | + return url; |
| 79 | +} |
| 80 | + |
| 81 | +/** |
| 82 | + * `fetch` for untrusted, user-supplied image URLs. Only hosts on the |
| 83 | + * `IMAGE_ALLOWED_HOSTS` allowlist may be reached (every redirect hop included), |
| 84 | + * restricted to http(s), with a request timeout to guard against hanging hosts. |
| 85 | + */ |
| 86 | +export async function secureFetch( |
| 87 | + rawUrl: string, |
| 88 | + init: Parameters<typeof undiciFetch>[1] = {} |
| 89 | +): Promise<Awaited<ReturnType<typeof undiciFetch>>> { |
| 90 | + const url = assertAllowedUrl(rawUrl); |
| 91 | + const controller = new AbortController(); |
| 92 | + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); |
| 93 | + try { |
| 94 | + return await undiciFetch(url, { |
| 95 | + ...init, |
| 96 | + signal: controller.signal, |
| 97 | + dispatcher: guardedAgent |
| 98 | + }); |
| 99 | + } finally { |
| 100 | + clearTimeout(timer); |
| 101 | + } |
| 102 | +} |
0 commit comments