From 47fb7a655de33958b0c923490ab244343eb5fc44 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Tue, 20 Oct 2026 05:10:42 +0300 Subject: [PATCH 1/2] fix(ssrf): keep spec credentials on their origin and normalize host lists safeFetch re-sent the caller's headers (the spec host's Authorization or API key) on every redirect hop, so a spec host with an open redirect, or a compromised one, could forward the token to any origin. The redirect loop now drops all caller headers once a hop leaves the original origin (another host, port, or an https -> http downgrade) and never restores them later in the chain. allowedHosts / blockedHosts were matched with an exact string compare against new URL().hostname, which keeps a trailing root dot, so `http://internal.corp./` slipped past `blockedHosts: ['internal.corp']` (the only host guard when allowInternalIPs is on). Hostnames and list entries are now compared lowercase with trailing dots and IPv6 brackets removed, in assertUrlSafe, isBlockedHostname and the $ref canRead allow-list. The built-in internal names (localhost., metadata.google.internal.) use the same normalization. Hardening: IPv6 addresses that embed an IPv4 address (NAT64 64:ff9b::/96, 6to4 2002::/16, IPv4-compatible and IPv4-translated forms, uncompressed mapped forms) are now checked against the IPv4 ranges, and the local-use NAT64 prefix 64:ff9b:1::/48 is blocked. Regression tests cover both reported issues through safeFetch and fromURL over real loopback servers, the $ref canRead filter, and the IPv6 forms. --- docs/configuration.md | 4 +- docs/ssrf-prevention.md | 21 ++++ src/__tests__/generator.spec.ts | 64 +++++++++++ src/__tests__/ssrf.spec.ts | 191 ++++++++++++++++++++++++++++++++ src/generator.ts | 7 +- src/ssrf.ts | 128 +++++++++++++++++---- src/types.ts | 8 +- 7 files changed, 398 insertions(+), 25 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index a0b8cb6..661e4b8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -27,7 +27,7 @@ const generator = await OpenAPIToolGenerator.fromURL(url, { | ----------------- | -------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `dereference` | `boolean` | `true` | Resolve all `$ref` pointers in the spec | | `baseUrl` | `string` | `''` | Override server URLs from the spec | -| `headers` | `Record` | `{}` | Custom HTTP headers for URL loading | +| `headers` | `Record` | `{}` | Custom HTTP headers for URL loading. Sent only to the spec URL's origin: dropped once a redirect leaves it, and never sent to external `$ref` hosts | | `timeout` | `number` | `30000` | HTTP request timeout in milliseconds | | `validate` | `boolean` | `true` | Validate the OpenAPI document on load | | `followRedirects` | `boolean` | `true` | Follow HTTP redirects when loading from URL | @@ -44,6 +44,8 @@ Controls how external `$ref` pointers are resolved during dereferencing. See [SS | `allowedProtocols` | `string[]` | `['http', 'https']` | Protocols allowed for external refs | | `allowedHosts` | `string[]` | `[]` (all allowed) | Whitelist specific hostnames | | `blockedHosts` | `string[]` | `[]` | Additional hostnames to block | + +Host entries match case-insensitively and ignore trailing root dots and IPv6 brackets: `['internal.corp']` also blocks `internal.corp.` and `INTERNAL.CORP`. | `allowInternalIPs` | `boolean` | `false` | Disable built-in SSRF protection | --- diff --git a/docs/ssrf-prevention.md b/docs/ssrf-prevention.md index f6cab04..2aa1515 100644 --- a/docs/ssrf-prevention.md +++ b/docs/ssrf-prevention.md @@ -46,9 +46,24 @@ Out of the box, the following are blocked: | `::1` | IPv6 loopback | | `fd00::/8` | IPv6 Unique Local Address | | `fe80::/10` | IPv6 link-local | +| `64:ff9b:1::/48` | Local-use NAT64 prefix (never globally routable) | Bracketed IPv6 forms (`[::1]`, `[fd00:...]`, `[fe80:...]`) are also blocked. +IPv6 addresses that embed an IPv4 address are checked against the IPv4 ranges above, because a NAT64 gateway, 6to4 relay, or dual-stack host may carry them to that IPv4 target: + +| Form | Example | Embedded IPv4 | +|------|---------|---------------| +| IPv4-mapped (`::ffff:0:0/96`) | `::ffff:7f00:1` | `127.0.0.1` | +| IPv4-translated (`::ffff:0:0:0/96`) | `::ffff:0:a00:1` | `10.0.0.1` | +| IPv4-compatible (`::/96`, deprecated) | `::7f00:1` | `127.0.0.1` | +| NAT64 well-known prefix (`64:ff9b::/96`) | `64:ff9b::a9fe:a9fe` | `169.254.169.254` | +| 6to4 (`2002::/16`) | `2002:c0a8:101::1` | `192.168.1.1` | + +### Hostname Matching + +Hostnames are compared in a normalized form: lowercase, with trailing root dots and IPv6 brackets removed. This applies to the built-in names above and to every `allowedHosts` / `blockedHosts` entry, on both sides of the comparison. `localhost.`, `LOCALHOST`, and `localhost` are the same host, so a `blockedHosts: ['internal.corp']` entry also blocks `http://internal.corp./`. + --- ## Configuration @@ -140,6 +155,12 @@ Every network fetch -- the spec URL in `fromURL` and each external `$ref` -- the 3. Opens a fresh socket for every request, so a pooled keep-alive socket that was connected elsewhere is never reused 4. Re-validates every redirect hop before following it (`$ref` fetches refuse redirects entirely) +### Headers and Redirects + +`LoadOptions.headers` (typically the spec host's `Authorization` or API key) is sent only while the request stays on the origin of the URL passed to `fromURL`. If a redirect leaves that origin (another host, another port, or an `https` → `http` downgrade), **all** caller headers are dropped for that hop and for every later hop, even if the chain later returns to the original origin. An open redirect on the spec host therefore cannot pass your credentials to a third party. External `$ref` fetches never send `headers`. + +If your spec host redirects to a different origin that also needs credentials, load the final URL directly, or pass `followRedirects: false` to stop at the redirect. + Connection pinning needs Node's `node:http`/`node:https`. On runtimes without them (Web/edge), and whenever you pass a custom `fetchImpl` to `safeFetch`, only the pre-fetch checks apply. For untrusted input there, combine `allowedHosts` with network egress controls. --- diff --git a/src/__tests__/generator.spec.ts b/src/__tests__/generator.spec.ts index 5ba729c..7f2b614 100644 --- a/src/__tests__/generator.spec.ts +++ b/src/__tests__/generator.spec.ts @@ -257,6 +257,49 @@ paths: expect(receivedAuth).toBe('value'); }); + it('should not forward caller headers to a cross-origin redirect target', async () => { + const cdn = createLoopbackServer(() => (_req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(specJson('Redirected')); + }); + const cdnBase = await cdn.listen(); + loopback.reset(); + handler = (_req, res) => { + res.writeHead(302, { location: `${cdnBase}/spec.json` }); + res.end(); + }; + try { + const generator = await OpenAPIToolGenerator.fromURL( + `${baseUrl}/api.json`, + internal({ headers: { Authorization: 'Bearer SECRET-TOKEN', 'X-API-Key': 'SECRET-KEY' } }), + ); + expect(generator.getDocument().info.title).toBe('Redirected'); + expect(loopback.requests[0].headers.authorization).toBe('Bearer SECRET-TOKEN'); + expect(cdn.requests).toHaveLength(1); + expect(cdn.requests[0].headers.authorization).toBeUndefined(); + expect(cdn.requests[0].headers['x-api-key']).toBeUndefined(); + } finally { + await cdn.close(); + } + }); + + it('should apply blockedHosts to the trailing-dot form of the spec host', async () => { + loopback.reset(); + handler = (_req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(specJson('Blocked')); + }; + const port = new URL(baseUrl).port; + for (const host of ['localhost', 'localhost.', 'LOCALHOST.']) { + await expect( + OpenAPIToolGenerator.fromURL(`http://${host}:${port}/api.json`, { + refResolution: { allowInternalIPs: true, blockedHosts: ['localhost'] }, + }), + ).rejects.toThrow(/blocked/); + } + expect(loopback.requests).toHaveLength(0); + }); + it('should not follow redirects when followRedirects is false', async () => { handler = (_req, res) => { res.writeHead(302, { location: `${baseUrl}/elsewhere` }); @@ -2127,6 +2170,27 @@ describe('SSRF Prevention - $ref resolution security', () => { expect(canRead({ url: 'https://evil.com/schema.json' })).toBe(false); }); + it('should match allowedHosts / blockedHosts regardless of case and trailing root dots', async () => { + const allowGen = await OpenAPIToolGenerator.fromJSON(minimalSpec, { + refResolution: { allowedHosts: ['Schemas.Example.com.'] }, + }); + await allowGen.generateTools(); + const allowCanRead = derefSpy.mock.calls[0][1]?.resolve?.http?.canRead; + expect(allowCanRead({ url: 'https://schemas.example.com/user.json' })).toBe(true); + expect(allowCanRead({ url: 'https://schemas.example.com./user.json' })).toBe(true); + expect(allowCanRead({ url: 'https://schemas.example.org/user.json' })).toBe(false); + + const blockGen = await OpenAPIToolGenerator.fromJSON(minimalSpec, { + refResolution: { blockedHosts: ['Evil.com'] }, + }); + await blockGen.generateTools(); + const blockCanRead = derefSpy.mock.calls[1][1]?.resolve?.http?.canRead; + expect(blockCanRead({ url: 'https://evil.com/schema.json' })).toBe(false); + expect(blockCanRead({ url: 'https://evil.com./schema.json' })).toBe(false); + expect(blockCanRead({ url: 'https://EVIL.COM../schema.json' })).toBe(false); + expect(blockCanRead({ url: 'https://good.com/schema.json' })).toBe(true); + }); + it('should support exotic protocols in allowedProtocols', async () => { const generator = await OpenAPIToolGenerator.fromJSON(minimalSpec, { refResolution: { allowedProtocols: ['https', 'ftp'] }, diff --git a/src/__tests__/ssrf.spec.ts b/src/__tests__/ssrf.spec.ts index f5a4525..e9289f9 100644 --- a/src/__tests__/ssrf.spec.ts +++ b/src/__tests__/ssrf.spec.ts @@ -9,6 +9,8 @@ import { isBlockedHostname, decodeIpv4MappedIpv6, normalizeSsrfOptions, + normalizeHostname, + hostListIncludes, defaultLookup, makePinnedLookup, pickHttpModule, @@ -103,6 +105,38 @@ describe('ssrf: isBlockedAddress (IP literal ranges)', () => { expect(isBlockedAddress('[::ffff:a9fe:a9fe]')).toBe(true); // hex form of 169.254.169.254 }); + it.each([ + '::127.0.0.1', // IPv4-compatible (deprecated) + '[::7f00:1]', // IPv4-compatible, as new URL() spells it + '::ffff:0:127.0.0.1', // IPv4-translated (SIIT) + '::ffff:0:a00:1', + '64:ff9b::127.0.0.1', // NAT64 well-known prefix (RFC 6052) + '[64:ff9b::a9fe:a9fe]', // NAT64 → 169.254.169.254 + '64:ff9b::a00:1', // NAT64 → 10.0.0.1 + '64:ff9b:1::808:808', // local-use NAT64 prefix (RFC 8215): never global + '2002:7f00:1::1', // 6to4 → 127.0.0.1 + '2002:a9fe:a9fe::', // 6to4 → 169.254.169.254 + '2002:c0a8:101::1', // 6to4 → 192.168.1.1 + '0:0:0:0:0:0:0:1', // uncompressed loopback + '0:0:0:0:0:ffff:7f00:1', // uncompressed IPv4-mapped loopback + ])('blocks IPv6 transition form %s that embeds a non-public IPv4', (ip) => { + expect(isBlockedAddress(ip)).toBe(true); + }); + + it.each(['64:ff9b::808:808', '2002:808:808::1', '::ffff:0:808:808', '[64:ff9b::1.1.1.1]'])( + 'allows IPv6 transition form %s that embeds a public IPv4', + (ip) => { + expect(isBlockedAddress(ip)).toBe(false); + }, + ); + + it.each(['1::2::3', '1:2:3', '1:2:3:4:5:6:7:8::', 'g::1', '::ffff:999.1.1.1'])( + 'returns false for malformed IPv6 %s', + (ip) => { + expect(isBlockedAddress(ip)).toBe(false); + }, + ); + it('returns false for non-IP hostnames', () => { expect(isBlockedAddress('example.com')).toBe(false); expect(isBlockedAddress('127.0.0.1.nip.io')).toBe(false); // string is not an IP literal @@ -139,6 +173,43 @@ describe('ssrf: isBlockedHostname', () => { expect(isBlockedHostname('127.0.0.1', { ...OPEN, allowInternalIPs: true })).toBe(false); expect(isBlockedHostname('127.0.0.1', { ...OPEN, allowInternalIPs: true, blockedHosts: ['127.0.0.1'] })).toBe(true); }); + it.each([false, true])( + 'matches blockedHosts regardless of case, trailing root dots, or IPv6 brackets (allowInternalIPs=%s)', + (allowInternalIPs) => { + const cfg: ResolvedSsrfOptions = { ...OPEN, allowInternalIPs, blockedHosts: ['Internal.Corp.', '2001:db8::1'] }; + expect(isBlockedHostname('internal.corp', cfg)).toBe(true); + expect(isBlockedHostname('internal.corp.', cfg)).toBe(true); + expect(isBlockedHostname('INTERNAL.CORP..', cfg)).toBe(true); + expect(isBlockedHostname('[2001:db8::1]', cfg)).toBe(true); + expect(isBlockedHostname('public.example', cfg)).toBe(false); + }, + ); + it('blocks the fully-qualified (trailing-dot) form of built-in internal names', () => { + expect(isBlockedHostname('localhost.', OPEN)).toBe(true); + expect(isBlockedHostname('metadata.google.internal.', OPEN)).toBe(true); + expect(isBlockedHostname('[::1]', OPEN)).toBe(true); + }); +}); + +describe('ssrf: normalizeHostname / hostListIncludes', () => { + it.each([ + ['Example.COM', 'example.com'], + ['example.com.', 'example.com'], + ['example.com..', 'example.com'], + [' api.example.com ', 'api.example.com'], + ['[::1]', '::1'], + ['[2001:DB8::1]', '2001:db8::1'], + ])('normalizes %j to %j', (input, expected) => { + expect(normalizeHostname(input)).toBe(expected); + }); + + it('compares the hostname and every entry in normalized form', () => { + expect(hostListIncludes(['API.Example.com.'], 'api.example.com')).toBe(true); + expect(hostListIncludes(['api.example.com'], 'api.example.com.')).toBe(true); + expect(hostListIncludes(['::1'], '[::1]')).toBe(true); + expect(hostListIncludes(['api.example.com'], 'api.example.org')).toBe(false); + expect(hostListIncludes([], 'api.example.com')).toBe(false); + }); }); describe('ssrf: normalizeSsrfOptions', () => { @@ -225,6 +296,48 @@ describe('ssrf: assertUrlSafe', () => { await expect(assertUrlSafe('http://blocked.internal/x', cfg, lookupTo())).rejects.toThrow(SsrfError); }); + it.each([false, true])( + 'blockedHosts also blocks the trailing-dot (FQDN) form of the host (allowInternalIPs=%s)', + async (allowInternalIPs) => { + const cfg: ResolvedSsrfOptions = { ...OPEN, allowInternalIPs, blockedHosts: ['api.example.com'] }; + const lookup = lookupTo('93.184.216.34'); + await expect(assertUrlSafe('https://api.example.com./x', cfg, lookup)).rejects.toThrow(/blocked/); + await expect(assertUrlSafe('https://API.example.com../x', cfg, lookup)).rejects.toThrow(/blocked/); + expect(lookup).not.toHaveBeenCalled(); + }, + ); + + it('matches allowedHosts / blockedHosts entries regardless of case or trailing dots', async () => { + const lookup = lookupTo('93.184.216.34'); + const allow: ResolvedSsrfOptions = { ...OPEN, allowedHosts: ['API.Example.com.'] }; + await expect(assertUrlSafe('https://api.example.com/x', allow, lookup)).resolves.toHaveLength(1); + await expect(assertUrlSafe('https://api.example.com./x', allow, lookup)).resolves.toHaveLength(1); + await expect(assertUrlSafe('https://api.example.org/x', allow, lookup)).rejects.toThrow(/allowed-hosts/); + + const block: ResolvedSsrfOptions = { ...OPEN, allowInternalIPs: true, blockedHosts: ['Blocked.Internal.'] }; + await expect(assertUrlSafe('http://blocked.internal/x', block, lookup)).rejects.toThrow(/blocked/); + }); + + it('rejects the trailing-dot form of a built-in internal name before resolving it', async () => { + const lookup = lookupTo('93.184.216.34'); // even if DNS lied, the name list decides first + await expect(assertUrlSafe('http://localhost./x', OPEN, lookup)).rejects.toThrow(SsrfError); + await expect(assertUrlSafe('http://metadata.google.internal./x', OPEN, lookup)).rejects.toThrow(SsrfError); + expect(lookup).not.toHaveBeenCalled(); + }); + + it('rejects IPv6 literals that embed an internal IPv4 (NAT64 / 6to4 / IPv4-compatible)', async () => { + const lookup = lookupTo('93.184.216.34'); + await expect(assertUrlSafe('http://[64:ff9b::169.254.169.254]/x', OPEN, lookup)).rejects.toThrow(SsrfError); + await expect(assertUrlSafe('http://[2002:a00:1::1]/x', OPEN, lookup)).rejects.toThrow(SsrfError); + await expect(assertUrlSafe('http://[::127.0.0.1]/x', OPEN, lookup)).rejects.toThrow(SsrfError); + expect(lookup).not.toHaveBeenCalled(); + }); + + it('rejects DNS names that resolve to a NAT64 address embedding an internal IPv4', async () => { + const lookup = lookupTo('64:ff9b::a00:5'); + await expect(assertUrlSafe('https://nat64.attacker.example/x', OPEN, lookup)).rejects.toThrow(/64:ff9b::a00:5/); + }); + it('fails CLOSED when the resolver errors (no silent fail-open)', async () => { const lookup = jest.fn(async () => { throw new Error('ENOTFOUND'); @@ -339,6 +452,59 @@ describe('ssrf: safeFetch (fetch-transport path via injected fetchImpl)', () => expect(await res.text()).toBe('ok'); }); + describe('caller headers across redirects', () => { + const AUTH = { Authorization: 'Bearer SECRET-TOKEN', 'X-API-Key': 'SECRET-KEY' }; + const sentHeaders = (fetchImpl: jest.Mock) => + fetchImpl.mock.calls.map(([, init]: [string, RequestInit]) => init.headers); + + it('keeps caller headers on same-origin redirect hops', async () => { + const fetchImpl = jest + .fn() + .mockResolvedValueOnce(mockResponse({ status: 302, location: '/v2/openapi.json' })) + .mockResolvedValueOnce(mockResponse({ status: 200, body: '{}' })); + await safeFetch('https://api.example.com/openapi.json', { + ssrf: OPEN, + headers: AUTH, + fetchImpl, + lookup: lookupTo('93.184.216.34'), + }); + expect(sentHeaders(fetchImpl)).toEqual([AUTH, AUTH]); + }); + + it.each([ + ['another host', 'https://cdn.example.com/spec.json'], + ['another port', 'https://api.example.com:8443/spec.json'], + ['a scheme downgrade', 'http://api.example.com/openapi.json'], + ])('drops caller headers when a redirect leaves the origin (%s)', async (_label, location) => { + const fetchImpl = jest + .fn() + .mockResolvedValueOnce(mockResponse({ status: 302, location })) + .mockResolvedValueOnce(mockResponse({ status: 200, body: '{}' })); + await safeFetch('https://api.example.com/openapi.json', { + ssrf: OPEN, + headers: AUTH, + fetchImpl, + lookup: lookupTo('93.184.216.34'), + }); + expect(sentHeaders(fetchImpl)).toEqual([AUTH, undefined]); + }); + + it('never restores caller headers once the chain has left the origin', async () => { + const fetchImpl = jest + .fn() + .mockResolvedValueOnce(mockResponse({ status: 302, location: 'https://cdn.example.com/spec.json' })) + .mockResolvedValueOnce(mockResponse({ status: 302, location: 'https://api.example.com/openapi.json?again' })) + .mockResolvedValueOnce(mockResponse({ status: 200, body: '{}' })); + await safeFetch('https://api.example.com/openapi.json', { + ssrf: OPEN, + headers: AUTH, + fetchImpl, + lookup: lookupTo('93.184.216.34'), + }); + expect(sentHeaders(fetchImpl)).toEqual([AUTH, undefined, undefined]); + }); + }); + it('throws on redirect loops exceeding maxRedirects', async () => { const fetchImpl = jest.fn(async () => mockResponse({ status: 302, location: 'https://api.example.com/again' })); await expect( @@ -515,6 +681,31 @@ describe('ssrf: safeFetch over the Node transport (real server)', () => { } }); + it('sends caller headers to the spec origin but not to a cross-origin redirect target', async () => { + const other = createLoopbackServer(() => (_req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('{}'); + }); + const otherBase = await other.listen(); + serverHandler = (_req, res) => { + res.writeHead(302, { location: `${otherBase}/spec.json` }); + res.end(); + }; + try { + const res = await safeFetch(`http://127.0.0.1:${serverPort}/spec.json`, { + ssrf: { ...OPEN, allowInternalIPs: true }, + headers: { Authorization: 'Bearer SECRET-TOKEN', 'X-API-Key': 'SECRET-KEY' }, + }); + expect(res.status).toBe(200); + expect(loopback.requests[0].headers.authorization).toBe('Bearer SECRET-TOKEN'); + expect(other.requests).toHaveLength(1); + expect(other.requests[0].headers.authorization).toBeUndefined(); + expect(other.requests[0].headers['x-api-key']).toBeUndefined(); + } finally { + await other.close(); + } + }); + it('dials the validated address instead of re-resolving the host at connect time (DNS rebinding)', async () => { const connectTimeLookup = jest .spyOn(dns, 'lookup') diff --git a/src/generator.ts b/src/generator.ts index 9e690f4..6d41ba7 100644 --- a/src/generator.ts +++ b/src/generator.ts @@ -36,7 +36,7 @@ import { Validator } from './validator'; import { GenerationError, LoadError, OverlayError, ParseError } from './errors'; import { BUILTIN_FORMAT_RESOLVERS, resolveSchemaFormats } from './format-resolver'; import { emitToolTypeScript } from './type-signature'; -import { isBlockedHostname, normalizeSsrfOptions, safeFetch } from './ssrf'; +import { hostListIncludes, isBlockedHostname, normalizeSsrfOptions, safeFetch } from './ssrf'; /** MCP hard limit for tool name length (spec revision 2025-11-25, SEP-986) */ const MCP_MAX_TOOL_NAME_LENGTH = 128; @@ -527,7 +527,6 @@ export class OpenAPIToolGenerator { // Configure HTTP/HTTPS resolver with security filtering if (hasNetworkProtocol) { const hasHostAllowlist = refOpts.allowedHosts.length > 0; - const hostAllowSet = new Set(refOpts.allowedHosts); resolveConfig['http'] = { // SECURITY: never auto-follow HTTP redirects when resolving external @@ -552,8 +551,8 @@ export class OpenAPIToolGenerator { return false; } - // Check host allowlist (if configured) - if (hasHostAllowlist && !hostAllowSet.has(parsed.hostname)) { + // Check host allowlist (if configured), case- and trailing-dot-insensitively + if (hasHostAllowlist && !hostListIncludes(refOpts.allowedHosts, parsed.hostname)) { return false; } diff --git a/src/ssrf.ts b/src/ssrf.ts index 517450d..e84016b 100644 --- a/src/ssrf.ts +++ b/src/ssrf.ts @@ -15,7 +15,11 @@ * * This module validates the **resolved IP**, not just the hostname string: * - parses/normalizes IPv4 (incl. numeric/hex/octal forms canonicalized by - * `new URL()`) and IPv4-mapped IPv6 before range checks; + * `new URL()`), IPv4-mapped IPv6, and the IPv6 transition forms that embed + * an IPv4 (NAT64, 6to4, IPv4-compatible/translated) before range checks; + * - matches `allowedHosts` / `blockedHosts` (and the built-in internal names) + * on a normalized hostname — lowercase, trailing root dot(s) and IPv6 + * brackets removed — so `host.` can't dodge an entry for `host`; * - blocks loopback / private (RFC 1918) / CGNAT (RFC 6598) / link-local / * multicast / unspecified / reserved ranges and cloud-metadata endpoints; * - resolves the hostname (Node, via `node:dns`) and rejects if **any** @@ -29,7 +33,8 @@ * original hostname is preserved for the `Host` header and TLS SNI, and * every request opens a fresh socket (never a pooled keep-alive one); * - re-validates every redirect hop ({@link safeFetch}) instead of letting the - * HTTP client follow 3xx blindly. + * HTTP client follow 3xx blindly, and stops sending the caller's headers + * (spec-host credentials) once a redirect leaves the original origin. * * Node-aware: DNS resolution lazily imports `node:dns` and IP pinning lazily * imports `node:http`/`node:https`. On runtimes without them (Web/edge) the @@ -85,6 +90,25 @@ export function normalizeSsrfOptions(refResolution?: RefResolutionOptions): Reso }; } +/** + * Canonical form of a hostname for allow/deny-list matching: trimmed, + * lowercase, IPv6 brackets removed, and trailing root dot(s) dropped. + * `API.example.com.` and `api.example.com` name the same DNS host, as do + * `[::1]` and `::1`, so an exact string match would let one spelling dodge a + * list entry written in the other. + */ +export function normalizeHostname(hostname: string): string { + let h = hostname.trim().toLowerCase(); + if (h.startsWith('[') && h.endsWith(']')) h = h.slice(1, -1); + return h.replace(/\.+$/, ''); +} + +/** Does `list` contain `hostname`? Both sides are compared in {@link normalizeHostname} form. */ +export function hostListIncludes(list: readonly string[], hostname: string): boolean { + const host = normalizeHostname(hostname); + return list.some((entry) => normalizeHostname(entry) === host); +} + /** * Decode an IPv4-mapped IPv6 host (`::ffff:169.254.169.254` or its hex form * `::ffff:a9fe:a9fe`, optionally bracketed) to its embedded dotted-quad IPv4, or @@ -136,6 +160,49 @@ function isBlockedIpv4(octets: [number, number, number, number]): boolean { return false; } +/** + * Expand an IPv6 address (no brackets or zone) into its eight 16-bit groups, + * or `null` if it is malformed. Accepts `::` compression and a trailing + * dotted-quad (`::ffff:1.2.3.4`). + */ +function parseIpv6(host: string): number[] | null { + let h = host; + const dotted = /^(.*:)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(h); + if (dotted) { + const v4 = parseIpv4(dotted[2]); + if (!v4) return null; + h = `${dotted[1]}${((v4[0] << 8) | v4[1]).toString(16)}:${((v4[2] << 8) | v4[3]).toString(16)}`; + } + const halves = h.split('::'); + if (halves.length > 2) return null; + const split = (part: string) => (part === '' ? [] : part.split(':')); + const head = split(halves[0]); + const tail = halves.length === 2 ? split(halves[1]) : []; + const missing = 8 - head.length - tail.length; + if (halves.length === 2 ? missing < 1 : missing !== 0) return null; + const groups = [...head, ...new Array(halves.length === 2 ? missing : 0).fill('0'), ...tail]; + if (!groups.every((g) => /^[0-9a-f]{1,4}$/.test(g))) return null; + return groups.map((g) => parseInt(g, 16)); +} + +/** + * The IPv4 address embedded in an IPv6 transition form, or `null`. A host or + * network translator (NAT64 gateway, 6to4 relay, dual-stack kernel) may carry + * such an address to its IPv4 target, so a public-looking IPv6 address can + * still reach an internal IPv4 one. + */ +function embeddedIpv4(groups: number[]): [number, number, number, number] | null { + const [g0, g1, g2, g3, g4, g5, g6, g7] = groups; + const octets = (hi: number, lo: number): [number, number, number, number] => [hi >> 8, hi & 0xff, lo >> 8, lo & 0xff]; + if (g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0) { + if (g4 === 0 && (g5 === 0 || g5 === 0xffff)) return octets(g6, g7); // ::a.b.c.d compatible, ::ffff:a.b.c.d mapped + if (g4 === 0xffff && g5 === 0) return octets(g6, g7); // ::ffff:0:a.b.c.d translated (SIIT) + } + if (g0 === 0x64 && g1 === 0xff9b && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0) return octets(g6, g7); // 64:ff9b::/96 NAT64 + if (g0 === 0x2002) return octets(g1, g2); // 2002::/16 6to4 + return null; +} + /** Is this IPv6 host (bracketed or not, possibly zoned) in a blocked range? */ function isBlockedIpv6(host: string): boolean { let h = host; @@ -149,13 +216,19 @@ function isBlockedIpv6(host: string): boolean { if (/^f[cd]/.test(lower)) return true; // fc00::/7 ULA (fc/fd) if (/^fe[89a-f]/.test(lower)) return true; // fe80::/10 link-local + fec0::/10 site-local if (/^ff/.test(lower)) return true; // ff00::/8 multicast - return false; + + const groups = parseIpv6(lower); + if (!groups) return false; + if (groups[0] === 0x64 && groups[1] === 0xff9b && groups[2] === 1) return true; // 64:ff9b:1::/48 local-use NAT64 + const v4 = embeddedIpv4(groups); + return v4 !== null && isBlockedIpv4(v4); } /** * Predicate: is `host` (an IP literal — dotted-quad IPv4, bracketed/zoned IPv6, - * or IPv4-mapped IPv6) in a blocked, non-public range? Returns `false` for - * non-IP-literal hostnames (use DNS resolution for those). + * or an IPv6 form embedding an IPv4: mapped, NAT64, 6to4, compatible) in a + * blocked, non-public range? Returns `false` for non-IP-literal hostnames (use + * DNS resolution for those). */ export function isBlockedAddress(host: string): boolean { let h = host; @@ -189,16 +262,13 @@ function isIpLiteral(hostname: string): boolean { * internal addresses are caught later, asynchronously, in {@link safeFetch}. */ export function isBlockedHostname(hostname: string, ssrf: ResolvedSsrfOptions): boolean { - if (ssrf.allowInternalIPs) { - return ssrf.blockedHosts.includes(hostname); - } - if (ssrf.blockedHosts.includes(hostname)) return true; + if (hostListIncludes(ssrf.blockedHosts, hostname)) return true; + if (ssrf.allowInternalIPs) return false; - const lower = hostname.toLowerCase(); - const stripped = lower.startsWith('[') && lower.endsWith(']') ? lower.slice(1, -1) : lower; - if (BLOCKED_HOSTNAMES.has(lower) || BLOCKED_HOSTNAMES.has(stripped)) return true; + const host = normalizeHostname(hostname); + if (BLOCKED_HOSTNAMES.has(host)) return true; - return isBlockedAddress(hostname); + return isBlockedAddress(host); } /** @@ -256,12 +326,12 @@ export async function assertUrlSafe( } const hostname = parsed.hostname; - if (ssrf.allowedHosts.length > 0 && !ssrf.allowedHosts.includes(hostname)) { + if (ssrf.allowedHosts.length > 0 && !hostListIncludes(ssrf.allowedHosts, hostname)) { throw new SsrfError(`Host "${hostname}" is not in the allowed-hosts list`, { url }); } if (ssrf.allowInternalIPs) { - if (ssrf.blockedHosts.includes(hostname)) { + if (hostListIncludes(ssrf.blockedHosts, hostname)) { throw new SsrfError(`Host "${hostname}" is blocked`, { url }); } return []; @@ -306,6 +376,11 @@ export async function assertUrlSafe( /** Options for {@link safeFetch}. */ export interface SafeFetchOptions { + /** + * Request headers (e.g. the spec host's `Authorization`). Sent only while the + * request stays on the origin of `url`: once a redirect leaves that origin + * (scheme, host, or port), they are dropped for every remaining hop. + */ headers?: Record; timeoutMs?: number; /** Follow 3xx redirects (re-validating each hop). @default true */ @@ -472,14 +547,16 @@ async function selectTransport(opts: SafeFetchOptions, url: string): Promise { const { headers, timeoutMs = 30000, followRedirects = true, maxRedirects = 5, ssrf, lookup } = opts; const transport = await selectTransport(opts, url); let current = url; + let hopHeaders = headers; for (let hop = 0; hop <= maxRedirects; hop++) { const pinned = await assertUrlSafe(current, ssrf, lookup); @@ -487,7 +564,12 @@ export async function safeFetch(url: string, opts: SafeFetchOptions): Promise controller.abort(), timeoutMs); let response: Response; try { - response = await transport(current, { headers, signal: controller.signal, pinned, maxBytes: opts.maxResponseBytes }); + response = await transport(current, { + headers: hopHeaders, + signal: controller.signal, + pinned, + maxBytes: opts.maxResponseBytes, + }); } finally { clearTimeout(timer); } @@ -502,7 +584,15 @@ export async function safeFetch(url: string, opts: SafeFetchOptions): Promise; From 1105f2ad4f0b58a6d568ec05a9a2ea471643139f Mon Sep 17 00:00:00 2001 From: David Antoon Date: Tue, 20 Oct 2026 05:18:48 +0300 Subject: [PATCH 2/2] fix(ssrf): never let a public ::ffff: tail allow a blocked IPv6 prefix isBlockedAddress decoded `::ffff:` found anywhere in the address and returned its verdict directly, so `fd00::ffff:808:808` (ULA), `fe80::ffff:808:808` (link-local) and `2002:a00:1::ffff:808:808` (6to4 for 10.0.0.1) were allowed because the tail spells 8.8.8.8. The mapped decode now only ever blocks; a public result falls through to the IPv6 prefix and embedded-IPv4 checks. normalizeHostname trims trailing dots with a loop instead of /\.+$/, which is quadratic on hostnames with long runs of dots. Moves the host-matching note in docs/configuration.md below the RefResolutionOptions table so the allowInternalIPs row still renders. --- docs/configuration.md | 2 +- src/__tests__/ssrf.spec.ts | 12 ++++++++++++ src/ssrf.ts | 11 ++++++++--- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 661e4b8..cbc0bc8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -44,9 +44,9 @@ Controls how external `$ref` pointers are resolved during dereferencing. See [SS | `allowedProtocols` | `string[]` | `['http', 'https']` | Protocols allowed for external refs | | `allowedHosts` | `string[]` | `[]` (all allowed) | Whitelist specific hostnames | | `blockedHosts` | `string[]` | `[]` | Additional hostnames to block | +| `allowInternalIPs` | `boolean` | `false` | Disable built-in SSRF protection | Host entries match case-insensitively and ignore trailing root dots and IPv6 brackets: `['internal.corp']` also blocks `internal.corp.` and `INTERNAL.CORP`. -| `allowInternalIPs` | `boolean` | `false` | Disable built-in SSRF protection | --- diff --git a/src/__tests__/ssrf.spec.ts b/src/__tests__/ssrf.spec.ts index e9289f9..09ba0d7 100644 --- a/src/__tests__/ssrf.spec.ts +++ b/src/__tests__/ssrf.spec.ts @@ -123,6 +123,14 @@ describe('ssrf: isBlockedAddress (IP literal ranges)', () => { expect(isBlockedAddress(ip)).toBe(true); }); + it.each([ + 'fd00::ffff:808:808', // ULA + 'fe80::ffff:808:808', // link-local + '2002:a00:1::ffff:808:808', // 6to4 → 10.0.0.1 + ])('blocks %s even though its ::ffff: tail spells a public IPv4', (ip) => { + expect(isBlockedAddress(ip)).toBe(true); + }); + it.each(['64:ff9b::808:808', '2002:808:808::1', '::ffff:0:808:808', '[64:ff9b::1.1.1.1]'])( 'allows IPv6 transition form %s that embeds a public IPv4', (ip) => { @@ -199,6 +207,8 @@ describe('ssrf: normalizeHostname / hostListIncludes', () => { [' api.example.com ', 'api.example.com'], ['[::1]', '::1'], ['[2001:DB8::1]', '2001:db8::1'], + ['a..b', 'a..b'], + ['...', ''], ])('normalizes %j to %j', (input, expected) => { expect(normalizeHostname(input)).toBe(expected); }); @@ -330,6 +340,8 @@ describe('ssrf: assertUrlSafe', () => { await expect(assertUrlSafe('http://[64:ff9b::169.254.169.254]/x', OPEN, lookup)).rejects.toThrow(SsrfError); await expect(assertUrlSafe('http://[2002:a00:1::1]/x', OPEN, lookup)).rejects.toThrow(SsrfError); await expect(assertUrlSafe('http://[::127.0.0.1]/x', OPEN, lookup)).rejects.toThrow(SsrfError); + await expect(assertUrlSafe('http://[fd00::ffff:8.8.8.8]/x', OPEN, lookup)).rejects.toThrow(SsrfError); + await expect(assertUrlSafe('http://[2002:a00:1::ffff:808:808]/x', OPEN, lookup)).rejects.toThrow(SsrfError); expect(lookup).not.toHaveBeenCalled(); }); diff --git a/src/ssrf.ts b/src/ssrf.ts index e84016b..5066ee9 100644 --- a/src/ssrf.ts +++ b/src/ssrf.ts @@ -100,7 +100,10 @@ export function normalizeSsrfOptions(refResolution?: RefResolutionOptions): Reso export function normalizeHostname(hostname: string): string { let h = hostname.trim().toLowerCase(); if (h.startsWith('[') && h.endsWith(']')) h = h.slice(1, -1); - return h.replace(/\.+$/, ''); + // A loop, not /\.+$/: that regex rescans from every dot, quadratic on `a....b`. + let end = h.length; + while (end > 0 && h[end - 1] === '.') end--; + return h.slice(0, end); } /** Does `list` contain `hostname`? Both sides are compared in {@link normalizeHostname} form. */ @@ -234,11 +237,13 @@ export function isBlockedAddress(host: string): boolean { let h = host; if (h.startsWith('[') && h.endsWith(']')) h = h.slice(1, -1); - // IPv4-mapped IPv6 → re-check as the embedded IPv4. + // IPv4-mapped IPv6 → re-check as the embedded IPv4. Block-only: the decoder + // finds `::ffff:` anywhere (e.g. `fd00::ffff:808:808`), so a public result + // must still fall through to the IPv6 prefix and embedded-IPv4 checks. const mapped = decodeIpv4MappedIpv6(host); if (mapped) { const o = parseIpv4(mapped); - if (o) return isBlockedIpv4(o); + if (o && isBlockedIpv4(o)) return true; } const v4 = parseIpv4(h);