(url: URL)
| 78 | } |
| 79 | |
| 80 | async function assertPublicTarget(url: URL): Promise<void> { |
| 81 | if (url.protocol !== 'https:' && url.protocol !== 'http:') { |
| 82 | throw new SsrfRefusedError(url.toString(), `scheme ${url.protocol} not allowed`); |
| 83 | } |
| 84 | // `url.hostname` strips brackets from `[::1]` → `::1`. Userinfo (user:pass@) |
| 85 | // never leaks into hostname per WHATWG, so we don't need to scrub that. |
| 86 | const hostname = url.hostname; |
| 87 | if (!hostname || hostname === 'localhost' || hostname.endsWith('.localhost')) { |
| 88 | throw new SsrfRefusedError(url.toString(), 'hostname resolves to loopback'); |
| 89 | } |
| 90 | const version = isIP(hostname); |
| 91 | if (version !== 0) { |
| 92 | if (isPrivateIpAddress(hostname)) { |
| 93 | throw new SsrfRefusedError(url.toString(), 'literal private/loopback address'); |
| 94 | } |
| 95 | return; |
| 96 | } |
| 97 | if (isNumericHostname(hostname)) { |
| 98 | throw new SsrfRefusedError(url.toString(), 'numeric-encoded hostname not allowed'); |
| 99 | } |
| 100 | try { |
| 101 | const records = await lookup(hostname, { all: true, verbatim: true }); |
| 102 | if (records.length === 0) { |
| 103 | throw new SsrfRefusedError(url.toString(), 'hostname did not resolve'); |
| 104 | } |
| 105 | if (records.some(r => isPrivateIpAddress(r.address))) { |
| 106 | throw new SsrfRefusedError(url.toString(), 'hostname resolves to private address'); |
| 107 | } |
| 108 | } catch (err) { |
| 109 | if (err instanceof SsrfRefusedError) throw err; |
| 110 | throw new SsrfRefusedError(url.toString(), `DNS lookup failed: ${err instanceof Error ? err.message : String(err)}`); |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | /** Build a `fetch`-shaped function gated by the SSRF guard. |
| 115 | * |
no test coverage detected