|
| 1 | +import dns from 'node:dns'; |
| 2 | +import net from 'node:net'; |
| 3 | +import { isFetchableHost } from '../fetchable-hosts'; |
| 4 | + |
| 5 | +/** |
| 6 | + * Fetching a URL that came from a request body means the server can be aimed at |
| 7 | + * anything it can reach, including services that are only listening because |
| 8 | + * they assumed nobody outside the machine could talk to them. The guard here is |
| 9 | + * three layers, because each one covers a hole the others leave open: |
| 10 | + * |
| 11 | + * 1. A host allowlist. The strongest of the three, and the reason the other |
| 12 | + * two are cheap: the set of hosts we ever need to fetch is three entries |
| 13 | + * long, so anything else — including every address literal — is refused |
| 14 | + * before a packet is sent. |
| 15 | + * 2. A resolved-address check, so an allowlisted name that happens to point at |
| 16 | + * a private address is still refused. |
| 17 | + * 3. Manual redirect handling, so a response from an allowed host cannot |
| 18 | + * redirect us somewhere we would never have agreed to fetch directly. |
| 19 | + * |
| 20 | + * Layer 2 does not eliminate DNS rebinding: `fetch` resolves the name again |
| 21 | + * itself, and nothing here pins the address we validated to the socket it opens. |
| 22 | + * Closing that properly needs a custom dispatcher, which is not worth it for a |
| 23 | + * route that only exists under `next dev` — and layer 1 already means an |
| 24 | + * attacker would need authoritative DNS for imgbox, slow.pics or imgsli, at |
| 25 | + * which point the rebinding is the least of the problems. |
| 26 | + */ |
| 27 | + |
| 28 | +const MAX_REDIRECTS = 5; |
| 29 | + |
| 30 | +/** Validation failures, kept distinct so the route can answer 400 rather than 500. */ |
| 31 | +export class UrlNotAllowedError extends Error { |
| 32 | + constructor(message: string) { |
| 33 | + super(message); |
| 34 | + this.name = 'UrlNotAllowedError'; |
| 35 | + } |
| 36 | +} |
| 37 | + |
| 38 | +function isPrivateIPv4(ip: string): boolean { |
| 39 | + const parts = ip.split('.').map(Number); |
| 40 | + const [a, b] = parts; |
| 41 | + |
| 42 | + if (a === 0) return true; // 0.0.0.0/8 "this network" |
| 43 | + if (a === 10) return true; // RFC1918 |
| 44 | + if (a === 127) return true; // loopback |
| 45 | + if (a === 169 && b === 254) return true; // link-local, incl. cloud metadata at 169.254.169.254 |
| 46 | + if (a === 172 && b >= 16 && b <= 31) return true; // RFC1918 |
| 47 | + if (a === 192 && b === 0) return true; // IETF protocol assignments |
| 48 | + if (a === 192 && b === 168) return true; // RFC1918 |
| 49 | + if (a === 198 && b >= 18 && b <= 19) return true; // benchmarking |
| 50 | + if (a === 100 && b >= 64 && b <= 127) return true; // carrier-grade NAT |
| 51 | + if (a >= 224) return true; // multicast, reserved, and the broadcast address |
| 52 | + |
| 53 | + return false; |
| 54 | +} |
| 55 | + |
| 56 | +/** |
| 57 | + * Whether `ip` belongs to a range that should never be reachable from a |
| 58 | + * user-supplied URL. |
| 59 | + * |
| 60 | + * Anything that is not a valid address returns false — callers reject unknown |
| 61 | + * hosts through the allowlist, and reporting a hostname as "private" here would |
| 62 | + * only produce a confusing error. |
| 63 | + */ |
| 64 | +export function isPrivateAddress(ip: string): boolean { |
| 65 | + if (net.isIPv4(ip)) { |
| 66 | + return isPrivateIPv4(ip); |
| 67 | + } |
| 68 | + |
| 69 | + if (net.isIPv6(ip)) { |
| 70 | + const lower = ip.toLowerCase(); |
| 71 | + |
| 72 | + // An IPv4-mapped address routes wherever its embedded IPv4 address |
| 73 | + // does, so ::ffff:127.0.0.1 is loopback. Nothing about the IPv6 text |
| 74 | + // says so, which is what makes a prefix check on the string miss it. |
| 75 | + const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(lower); |
| 76 | + if (mapped && net.isIPv4(mapped[1])) { |
| 77 | + return isPrivateIPv4(mapped[1]); |
| 78 | + } |
| 79 | + |
| 80 | + if (lower === '::' || lower === '::1') return true; |
| 81 | + if (/^f[cd]/.test(lower)) return true; // fc00::/7 unique local |
| 82 | + if (/^fe[89ab]/.test(lower)) return true; // fe80::/10 link local |
| 83 | + if (/^ff/.test(lower)) return true; // ff00::/8 multicast |
| 84 | + |
| 85 | + return false; |
| 86 | + } |
| 87 | + |
| 88 | + return false; |
| 89 | +} |
| 90 | + |
| 91 | +/** |
| 92 | + * Parses `raw` and rejects it unless it is an https URL on an allowlisted host |
| 93 | + * that resolves to a public address. |
| 94 | + */ |
| 95 | +export async function assertSafeUrl(raw: string | URL): Promise<URL> { |
| 96 | + let url: URL; |
| 97 | + try { |
| 98 | + url = new URL(raw); |
| 99 | + } catch { |
| 100 | + throw new UrlNotAllowedError(`Invalid URL: ${String(raw)}`); |
| 101 | + } |
| 102 | + |
| 103 | + if (url.protocol !== 'https:') { |
| 104 | + throw new UrlNotAllowedError(`Protocol not allowed: ${url.protocol}`); |
| 105 | + } |
| 106 | + |
| 107 | + // `URL.hostname` keeps the brackets around an IPv6 literal, and `net.isIPv6` |
| 108 | + // does not accept them — so `[::1]` reads as "not an IP address" unless they |
| 109 | + // come off first. |
| 110 | + const hostname = url.hostname.replace(/^\[|\]$/g, ''); |
| 111 | + |
| 112 | + if (!isFetchableHost(hostname)) { |
| 113 | + throw new UrlNotAllowedError(`Host not allowed: ${hostname}`); |
| 114 | + } |
| 115 | + |
| 116 | + const addresses = await dns.promises.lookup(hostname, { all: true }); |
| 117 | + for (const { address } of addresses) { |
| 118 | + if (isPrivateAddress(address)) { |
| 119 | + throw new UrlNotAllowedError(`Host resolves to a private address: ${hostname} -> ${address}`); |
| 120 | + } |
| 121 | + } |
| 122 | + |
| 123 | + return url; |
| 124 | +} |
| 125 | + |
| 126 | +/** |
| 127 | + * Fetches `raw` as text, validating the initial URL and every redirect it |
| 128 | + * follows. |
| 129 | + */ |
| 130 | +export async function safeFetchText(raw: string): Promise<string> { |
| 131 | + let url = await assertSafeUrl(raw); |
| 132 | + |
| 133 | + for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) { |
| 134 | + const res = await fetch(url, { redirect: 'manual' }); |
| 135 | + |
| 136 | + if (res.status < 300 || res.status >= 400) { |
| 137 | + return await res.text(); |
| 138 | + } |
| 139 | + |
| 140 | + const location = res.headers.get('location'); |
| 141 | + if (!location) { |
| 142 | + throw new Error(`Redirect with no Location header from ${url.href}`); |
| 143 | + } |
| 144 | + |
| 145 | + // Relative redirects are common and legal, so resolve against the URL we |
| 146 | + // just requested before revalidating. |
| 147 | + url = await assertSafeUrl(new URL(location, url)); |
| 148 | + } |
| 149 | + |
| 150 | + throw new Error(`Too many redirects starting from ${String(raw)}`); |
| 151 | +} |
0 commit comments