Files
TREK/server/src/utils/ssrfGuard.ts
T
Maurice a5745fd856 fix(ssrf): connect past a link-local DNS record instead of refusing the host (#2506)
Since 4.3.0 the guard resolves a name to every address it has, and a
single blocked address refused the whole name. LAN DNS servers commonly
publish a host's fe80:: address next to its IPv4, so a self-hosted IdP on
such a name failed every login with "Requests to link-local /
cloud-metadata addresses are not allowed". Immich, Synology and the other
strict-guard integrations on the same kind of name failed the same way.

Both guards now leave a blocked address out of the list the socket is
pinned to and connect over the rest, and a name that resolves to nothing
else is still refused. A private address among the remaining ones still
needs ALLOW_INTERNAL_NETWORK under the strict guard, and
ALLOW_LINK_LOCAL_IPS works as before.

Leaving addresses out is only safe if the blocked set is complete, so the
strict guard now blocks everything the admin lane does. The AWS IMDSv6
(fd00:ec2::/32) and Alibaba (100.100.100.200, 100.100.100.100) metadata
addresses sit in ULA and CGNAT space, which ALLOW_INTERNAL_NETWORK opened
to any URL a user can type. Both guards also read an IPv4-mapped or
IPv4-compatible address as the IPv4 it carries: Node prints an AAAA
record of ::a9fe:a9fe as ::169.254.169.254, which passed both guards, and
::ffff:100.100.100.200 passed the admin lane. Neither guard connects to a
link-local or metadata address now, and the strict guard never connects
to loopback either.
2026-09-26 10:36:02 +02:00

563 lines
25 KiB
TypeScript

import dns from 'node:dns/promises';
import { Agent } from 'undici';
import { readEnv } from '../app-config';
import { embeddedTransitionIpv4, expandIpv6 } from './ipv6';
// Frozen at import on purpose (legacy timing; tests reload the module to change it).
const ALLOW_INTERNAL_NETWORK = readEnv().net.allowInternalNetwork;
// Link-local addresses an admin listed in ALLOW_LINK_LOCAL_IPS, a rootless Podman
// host gateway being the reason (#2400). The parser never lets a cloud metadata
// address in, so the rest of 169.254.0.0/16 stays blocked. Frozen the same way.
const ALLOWED_LINK_LOCAL = new Set(readEnv().net.allowLinkLocalIps);
export interface SsrfResult {
allowed: boolean;
/** The first of `resolvedIps`, kept for the callers that name one address. */
resolvedIp?: string;
/**
* The addresses a connection to the name may use, IPv4 first: every address
* it resolves to, less the ones the guard never connects to. On a refusal,
* every address it resolved to.
*/
resolvedIps?: string[];
isPrivate: boolean;
error?: string;
}
/**
* Every address a name resolves to, IPv4 ahead of IPv6.
*
* One address was never enough. `dns.lookup(name)` hands back whichever record
* the resolver lists first, on Node 22 in the resolver's own order, and a
* dual-stack name whose AAAA comes first then pinned the connection to an IPv6
* that many hosts cannot reach on port 443 while the A record would have
* answered at once. Every address the guard allows goes to the socket, where
* Node tries the addresses in turn (autoSelectFamily, the default since Node
* 20), so an unreachable family costs a quarter of a second instead of the
* caller's whole timeout. IPv4 leads because that is the family that is
* reachable from the containers and LXCs TREK usually runs in.
*/
async function resolveAll(hostname: string): Promise<{ address: string; family: number }[]> {
const result = await dns.lookup(hostname, { all: true });
// The promise API answers an array for `all: true`; a resolver stub in a test
// may still answer one record, which is the same thing said shorter.
const list = (Array.isArray(result) ? result : [result]).filter((entry) => typeof entry?.address === 'string');
if (list.length === 0) {
const error = new Error(`getaddrinfo ENOTFOUND ${hostname}`) as NodeJS.ErrnoException;
error.code = 'ENOTFOUND';
throw error;
}
const family = (entry: { address: string; family: number }) => entry.family || (entry.address.includes(':') ? 6 : 4);
return [...list.filter((entry) => family(entry) === 4), ...list.filter((entry) => family(entry) !== 4)]
.map((entry) => ({ address: entry.address, family: family(entry) }));
}
/**
* The IPv4 an IPv4-mapped (`::ffff:a.b.c.d`) or IPv4-compatible (`::a.b.c.d`)
* address stands for, or null.
*
* Taken from the hextets rather than from the text, because `::ffff:127.0.0.1`
* and `::ffff:7f00:1` are one address in two spellings and `dns.lookup` picks
* which one comes back. The prefix regexes this replaces covered the dotted
* spelling of two ranges; every other mapped address walked past them. The
* compatible form is deprecated but still parsed, and Node prints an AAAA
* record of `::a9fe:a9fe` as `::169.254.169.254`. `::` and `::1` are left to
* the IPv6 checks, being the unspecified and loopback addresses themselves.
*/
function carriedIpv4(hextets: number[]): string | null {
const g = hextets;
if (g[0] || g[1] || g[2] || g[3] || g[4]) return null;
const mapped = g[5] === 0xffff;
const compatible = g[5] === 0 && (g[6] !== 0 || g[7] > 1);
if (!mapped && !compatible) return null;
return `${g[6] >> 8}.${g[6] & 0xff}.${g[7] >> 8}.${g[7] & 0xff}`;
}
// Blocked whatever ALLOW_INTERNAL_NETWORK says. The only way past is naming a single
// link-local address in ALLOW_LINK_LOCAL_IPS; loopback and metadata have none.
function isAlwaysBlocked(ip: string): boolean {
// Strip IPv6 brackets
const addr = ip.startsWith('[') ? ip.slice(1, -1) : ip;
// Loopback
if (addr.startsWith('127.') || addr === '::1') return true;
// Unspecified
if (addr.startsWith('0.')) return true;
// Link-local and cloud metadata: everything the admin lane refuses, apart from
// an address listed in ALLOW_LINK_LOCAL_IPS, which isPrivateNetwork then treats
// as the internal network it is. The AWS IMDSv6 and Alibaba addresses sit in
// ULA and CGNAT space, so without this ALLOW_INTERNAL_NETWORK would open them
// to a URL any user can type.
if (isLinkLocal(addr)) return true;
const hextets = expandIpv6(addr);
if (hextets) {
// The IPv6 unspecified address. Connecting to it lands on loopback, so it
// reaches a local service without naming one, and it has enough spellings
// (`::`, `::0`, `0:0:0:0:0:0:0:0`) that only the expanded hextets settle it.
// The `0.` check above never saw any of them: they begin with a colon.
if (hextets.every(h => h === 0)) return true;
// Loopback in any spelling, not only the `::1` a resolver prints.
if (hextets.slice(0, 7).every(h => h === 0) && hextets[7] === 1) return true;
// fe80::/10 spans fe80: through febf:, not just the four characters 'fe80'.
if ((hextets[0] & 0xffc0) === 0xfe80) return true;
// A mapped or compatible address inherits the verdict of the IPv4 it carries.
const carried = carriedIpv4(hextets);
if (carried) return isAlwaysBlocked(carried);
}
// IPv6 transition addresses (NAT64/6to4/Teredo) embedding a hard-blocked IPv4.
const embedded = embeddedTransitionIpv4(addr);
if (embedded) return isAlwaysBlocked(embedded);
return false;
}
// Blocked unless ALLOW_INTERNAL_NETWORK=true
function isPrivateNetwork(ip: string): boolean {
const addr = ip.startsWith('[') ? ip.slice(1, -1) : ip;
// A listed link-local address is a host on this machine's own network, so it
// needs ALLOW_INTERNAL_NETWORK here like any other.
if (ALLOWED_LINK_LOCAL.has(addr)) return true;
// RFC-1918 private ranges
if (addr.startsWith('10.')) return true;
if (/^172\.(1[6-9]|2\d|3[01])\./.test(addr)) return true;
if (addr.startsWith('192.168.')) return true;
// CGNAT / Tailscale shared address space (100.64.0.0/10)
if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(addr)) return true;
// IPv6 ULA (fc00::/7)
if (/^f[cd]/i.test(addr)) return true;
// A mapped or compatible address inherits the verdict of the IPv4 it carries.
// Deciding on the hextets rather than on three `::ffff:`-prefix regexes also
// covers the hex spelling and the CGNAT range those regexes never listed.
const hextets = expandIpv6(addr);
const carried = hextets && carriedIpv4(hextets);
if (carried) return isPrivateNetwork(carried);
// IPv6 transition addresses (NAT64/6to4/Teredo) embedding a private IPv4.
const embedded = embeddedTransitionIpv4(addr);
if (embedded) return isPrivateNetwork(embedded);
return false;
}
function isInternalHostname(hostname: string): boolean {
const h = hostname.toLowerCase();
return h.endsWith('.local') || h.endsWith('.internal') || h === 'localhost';
}
export async function checkSsrf(rawUrl: string, bypassInternalIpAllowed: boolean = false): Promise<SsrfResult> {
let url: URL;
try {
url = new URL(rawUrl);
} catch {
return { allowed: false, isPrivate: false, error: 'Invalid URL' };
}
if (!['http:', 'https:'].includes(url.protocol)) {
return { allowed: false, isPrivate: false, error: 'Only HTTP and HTTPS URLs are allowed' };
}
const hostname = url.hostname.toLowerCase();
// Resolve the name to every address it has and judge each one on its own.
let resolvedIps: string[];
try {
resolvedIps = (await resolveAll(hostname)).map((entry) => entry.address);
} catch (error_) {
const code = error_ instanceof Error && 'code' in error_ ? String(error_.code) : 'unknown';
return { allowed: false, isPrivate: false, error: `Could not resolve hostname (${code})` };
}
// An address the guard never connects to is left out of the list the socket
// gets, rather than refusing the name. A LAN DNS server commonly hands out a
// host's fe80:: address next to its IPv4, and refusing the name for it locked
// out every such host (#2506). Only a name with nothing else left is refused.
const usableIps = resolvedIps.filter((ip) => !isAlwaysBlocked(ip));
if (usableIps.length === 0) {
return {
allowed: false,
isPrivate: true,
resolvedIp: resolvedIps[0],
resolvedIps,
error: 'Requests to loopback and link-local addresses are not allowed',
};
}
const resolvedIp = usableIps[0];
// A private address among the rest decides for the whole name: the socket
// may end up on any address it is handed, so a list that mixes a public and a
// private address is a private target with a public alibi.
const privateIp = usableIps.find((ip) => isPrivateNetwork(ip));
if (privateIp || isInternalHostname(hostname)) {
if (!ALLOW_INTERNAL_NETWORK || bypassInternalIpAllowed) {
return {
allowed: false,
isPrivate: true,
resolvedIp: privateIp ?? resolvedIp,
resolvedIps,
error:
'Requests to private/internal network addresses are not allowed. Set ALLOW_INTERNAL_NETWORK=true to permit this for self-hosted setups.',
};
}
return { allowed: true, isPrivate: true, resolvedIp: privateIp ?? resolvedIp, resolvedIps: usableIps };
}
return { allowed: true, isPrivate: false, resolvedIp, resolvedIps: usableIps };
}
/** Link-local / cloud-metadata addresses — never a legitimate model host. */
function isLinkLocal(ip: string): boolean {
const addr = (ip.startsWith('[') ? ip.slice(1, -1) : ip).toLowerCase();
// IPv4 link-local — AWS, GCP, Azure and OpenStack all serve credentials from 169.254.169.254.
// An address listed in ALLOW_LINK_LOCAL_IPS is the exception; those never include it.
if (addr.startsWith('169.254.')) return !ALLOWED_LINK_LOCAL.has(addr);
// IPv6 link-local fe80::/10 — the whole range (fe80: … febf:), not just the fe80: prefix.
if (/^fe[89ab][0-9a-f]:/.test(addr)) return true;
// AWS IMDSv6 sits in a fixed ULA slot (fd00:ec2::/32); block it specifically while
// leaving the rest of fc00::/7 reachable (a self-hosted model server may
// legitimately sit on a ULA address). Read off the hextets, so every spelling counts.
const hextets = expandIpv6(addr);
if (hextets && hextets[0] === 0xfd00 && hextets[1] === 0x0ec2) return true;
// Alibaba Cloud ECS metadata (100.100.100.200, and .100) lives in CGNAT space
// (100.64.0.0/10), which safeFetchLlm otherwise allows for a LAN model server —
// so block the specific metadata IPs directly rather than the whole range.
if (addr === '100.100.100.200' || addr === '100.100.100.100') return true;
// An IPv4-mapped or IPv4-compatible address, dotted or hex, is the IPv4 it
// carries. So 169.254.169.254 cannot come back as ::ffff:a9fe:a9fe, or as the
// ::169.254.169.254 Node prints for a compatible AAAA record, and an address
// listed in ALLOW_LINK_LOCAL_IPS keeps its exception in every spelling.
const carried = hextets && carriedIpv4(hextets);
if (carried) return isLinkLocal(carried);
// NAT64/6to4/Teredo embedding one of the above link-local/metadata IPs — extract
// the embedded IPv4 and re-check. A transition address to a public or LAN IPv4
// stays allowed (safeFetchLlm deliberately permits private/loopback targets).
const embedded = embeddedTransitionIpv4(addr);
if (embedded) return isLinkLocal(embedded);
return false;
}
/**
* SSRF-safe fetch for a user-configurable LLM endpoint. Unlike safeFetch() this
* deliberately ALLOWS loopback and private/LAN targets — a local Ollama on
* localhost or a model server on the LAN is the normal, supported config, so
* blocking them would break a legitimate setup. It blocks only the link-local /
* cloud-metadata range (169.254.0.0/16, fe80::/10, the AWS/Alibaba metadata IPs),
* which is never a real LLM host but is the credential-theft SSRF target.
*
* Redirects are followed MANUALLY and re-validated on every hop. Checking only the
* initial URL is not enough: a validated public endpoint can 302 to
* `http://169.254.169.254/…`, and the DNS pin does NOT cover that hop — Node's
* net.connect skips the pinned `lookup` for an IP-literal host, so a redirect to a
* literal metadata IP would connect straight through. Following manually (like
* safeFetchFollow) means each hop is re-resolved, re-checked against isLinkLocal,
* and re-pinned. An http→https upgrade or a proxy redirect between LAN hosts still
* works because the check re-runs per hop rather than locking to the first IP.
*
* `responseTimeoutMs` raises undici's own five-minute ceiling for callers that
* legitimately wait longer (see safeFetchLlm). Left unset it keeps undici's
* default, which is what every other admin-configured endpoint wants.
*/
export async function safeFetchAdminConfigured(
url: string,
init?: RequestInit,
maxRedirects = 5,
responseTimeoutMs?: number,
): Promise<Response> {
let currentUrl = url;
let hopInit = init;
for (let hop = 0; ; hop++) {
let parsed: URL;
try {
parsed = new URL(currentUrl);
} catch {
throw new SsrfBlockedError('Invalid URL');
}
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new SsrfBlockedError('Only HTTP and HTTPS URLs are allowed');
}
let resolvedIps: string[];
try {
resolvedIps = (await resolveAll(parsed.hostname)).map((entry) => entry.address);
} catch (error_) {
const code = error_ instanceof Error && 'code' in error_ ? String(error_.code) : 'unknown';
throw new SsrfBlockedError(`Could not resolve hostname (${code})`);
}
// As in checkSsrf: a link-local or metadata address is left out of the list
// the socket gets, and only a name with nothing else left is refused. An
// fe80:: record beside an IdP's LAN address refused every OIDC login (#2506).
const usableIps = resolvedIps.filter((ip) => !isLinkLocal(ip));
if (usableIps.length === 0) {
throw new SsrfBlockedError('Requests to link-local / cloud-metadata addresses are not allowed');
}
const dispatcher = createPinnedDispatcher(usableIps, true, responseTimeoutMs);
const response = await fetch(currentUrl, { ...hopInit, redirect: 'manual', dispatcher } as any);
// Only a 3xx WITH a Location header is a redirect we follow; anything else
// (2xx/4xx/5xx, or a 3xx with no Location) is the final response.
const status = typeof response.status === 'number' ? response.status : 0;
const location = status >= 300 && status < 400 ? (response.headers?.get('location') ?? null) : null;
if (!location) return response;
if (hop >= maxRedirects) {
throw new SsrfBlockedError('Too many redirects');
}
let nextUrl: string;
try {
nextUrl = new URL(location, currentUrl).toString();
} catch {
throw new SsrfBlockedError('Invalid redirect location');
}
// Drain the redirect body so the connection can be reused/closed, then loop
// to re-resolve + re-check + re-pin the next hop.
void response.body?.cancel().catch(() => {});
hopInit = nextHopInit(hopInit, currentUrl, nextUrl, status);
currentUrl = nextUrl;
}
}
/**
* The original name, now the model lane specifically: the same guard as
* safeFetchAdminConfigured — an endpoint an admin configured, which may
* legitimately live on loopback or the LAN, and must still never reach
* link-local or a cloud metadata service — with LLM_TIMEOUT_MS as the response
* ceiling instead of undici's five minutes, so one setting governs the whole
* call rather than being overruled by a default nobody chose.
*
* The ceiling is read once per call rather than per hop, so a redirect chain is
* measured against one value even if the variable changes mid-chain.
*/
export function safeFetchLlm(url: string, init?: RequestInit, maxRedirects = 5): Promise<Response> {
return safeFetchAdminConfigured(url, init, maxRedirects, readEnv().integrations.llmTimeoutMs);
}
/**
* Thrown by safeFetch() when the URL is blocked by the SSRF guard.
*/
export class SsrfBlockedError extends Error {
constructor(message: string) {
super(message);
this.name = 'SsrfBlockedError';
}
}
export interface SafeFetchOptions {
rejectUnauthorized?: boolean;
}
/**
* SSRF-safe fetch wrapper. Validates the URL with checkSsrf(), then makes
* the request using a DNS-pinned dispatcher so the resolved IP cannot change
* between the check and the actual connection (DNS rebinding prevention).
*
* Pass `{ rejectUnauthorized: false }` for targets that use self-signed TLS
* certificates (e.g. a Synology NAS on a local network). The SSRF guard still
* applies — only the TLS certificate check is relaxed.
*
* Redirects are followed through safeFetchFollow, so every hop is re-checked and
* re-pinned. It used to hand the platform a `redirect: 'follow'` with a
* dispatcher pinned to the FIRST hop only — the same shape as
* GHSA-8mw6-xphx-886m, and pinning does not help there because Node skips the
* pinned lookup for an IP-literal host. Sixteen callers ride on this, several
* with a URL out of a per-user setting (Immich, Synology, AirTrail), and one
* (pipeAsset) streams the response back to the caller, which would have made a
* redirect to an internal service readable rather than blind.
*/
export async function safeFetch(url: string, init?: RequestInit, options?: SafeFetchOptions): Promise<Response> {
return safeFetchFollow(url, init, options);
}
/**
* Headers that must not survive a hop to another origin. Following a redirect
* by hand means the platform's own protection does not apply: undici drops
* `Authorization` across origins itself (Fetch, "HTTP-redirect fetch" step 13),
* so a manual follower that replays the caller's headers verbatim is strictly
* weaker than the fetch it replaced. The list is credentials-only on purpose —
* `User-Agent` has to survive, or the goo.gl chain in maps.service resolves to
* a different page than the one its coordinates are parsed out of.
*/
const CREDENTIAL_HEADERS = [
'authorization', 'proxy-authorization', 'cookie', 'cookie2',
'x-api-key', 'api-key', 'x-auth-token',
];
/** Headers that describe the body, and go when the body does. */
const BODY_HEADERS = ['content-type', 'content-length', 'content-encoding', 'content-language', 'content-location'];
/**
* Cross-origin per Fetch, with one carve-out: the same hostname moving from
* http to https is an upgrade, not a host change. A self-hosted IdP or ntfy
* behind a redirecting proxy does exactly that, and a strict origin compare
* would strip the credential those setups depend on.
*/
function isCrossOriginHop(from: string, to: string): boolean {
let a: URL, b: URL;
try { a = new URL(from); b = new URL(to); } catch { return true; }
if (a.origin === b.origin) return false;
return !(a.hostname === b.hostname && a.protocol === 'http:' && b.protocol === 'https:');
}
function stripHeaders(init: RequestInit | undefined, names: string[]): RequestInit | undefined {
if (!init?.headers) return init;
const headers = new Headers(init.headers as ConstructorParameters<typeof Headers>[0]);
for (const name of names) headers.delete(name);
return { ...init, headers };
}
/**
* The init for the next hop of a manual redirect follow. Both followers below
* go through this: following by hand opts out of the platform's own rules, so
* they have to be restated once rather than forgotten twice.
*/
function nextHopInit(
init: RequestInit | undefined,
currentUrl: string,
nextUrl: string,
status: number,
keepCredentials = false,
): RequestInit | undefined {
let next = init;
if (!keepCredentials && isCrossOriginHop(currentUrl, nextUrl)) {
next = stripHeaders(next, CREDENTIAL_HEADERS);
}
// RFC 9110 15.4.4 for 303, and 15.4.2/15.4.3 plus what every client actually
// does for 301/302 on a POST: the next hop is a GET with no body. Replaying
// the body would re-POST it — a plugin's client_secret included — at a host
// the first one merely pointed at.
const method = (next?.method ?? 'GET').toUpperCase();
if (status === 303 || ((status === 301 || status === 302) && method !== 'GET' && method !== 'HEAD')) {
next = { ...stripHeaders(next, BODY_HEADERS), method: 'GET', body: undefined };
}
return next;
}
export interface SafeFetchFollowOptions extends SafeFetchOptions {
/** Maximum number of redirects to follow before giving up. Defaults to 5. */
maxRedirects?: number;
/**
* Keep credential headers across an origin change. Off by default, and no
* caller needs it today — it exists so a future one that genuinely does has
* to say so here rather than route around the guard.
*/
keepCredentialsOnRedirect?: boolean;
/**
* When true, private/internal IPs that ALLOW_INTERNAL_NETWORK would normally
* permit are still blocked (matches `checkSsrf(url, true)`). Loopback,
* link-local and cloud metadata are always blocked regardless. Defaults to false.
*/
bypassInternalIpAllowed?: boolean;
}
/**
* SSRF-safe fetch that follows redirects MANUALLY, re-validating every hop.
*
* `safeFetch()` (and a one-shot `checkSsrf()` + `fetch(redirect:'follow')`) only
* guards the INITIAL URL: a validated public URL can 302-redirect to an internal
* IP that the platform fetch would then follow unchecked (redirect TOCTOU). This
* helper instead requests with `redirect: 'manual'`, and on every 3xx it resolves
* the `Location` header against the current URL, runs `checkSsrf()` on the new
* target, and only then fetches the next hop through a dispatcher pinned to THAT
* hop's resolved IP. Each hop is therefore SSRF-checked + DNS-pinned, while
* legitimate cross-host redirects (e.g. goo.gl → maps.google.com) still resolve
* because the dispatcher is re-pinned per hop rather than locked to the first IP.
*
* The returned Response is the first non-redirect response (or the last redirect
* if the hop limit is reached). `response.url` reflects the final hop so callers
* relying on the resolved URL keep working.
*/
export async function safeFetchFollow(
url: string,
init?: RequestInit,
options?: SafeFetchFollowOptions,
): Promise<Response> {
const maxRedirects = options?.maxRedirects ?? 5;
const rejectUnauthorized = options?.rejectUnauthorized ?? true;
const bypassInternalIpAllowed = options?.bypassInternalIpAllowed ?? false;
let currentUrl = url;
let hopInit = init;
for (let hop = 0; ; hop++) {
const ssrf = await checkSsrf(currentUrl, bypassInternalIpAllowed);
if (!ssrf.allowed) {
throw new SsrfBlockedError(ssrf.error ?? 'Request blocked by SSRF guard');
}
const dispatcher = createPinnedDispatcher(ssrf.resolvedIps ?? [ssrf.resolvedIp!], rejectUnauthorized);
const response = await fetch(currentUrl, {
...hopInit,
redirect: 'manual',
dispatcher,
} as any);
// Only a 3xx WITH a Location header is a redirect we follow; anything else
// (2xx/4xx/5xx, or a 3xx with no Location) is the final response.
const status = typeof response.status === 'number' ? response.status : 0;
const isRedirectStatus = status >= 300 && status < 400;
const location = isRedirectStatus ? (response.headers?.get('location') ?? null) : null;
if (!location) {
return response;
}
if (hop >= maxRedirects) {
throw new SsrfBlockedError('Too many redirects');
}
// Resolve relative redirects against the current URL, then loop to
// re-check + re-pin on the next iteration. Drain the body so the
// connection can be reused/closed.
let nextUrl: string;
try {
nextUrl = new URL(location, currentUrl).toString();
} catch {
throw new SsrfBlockedError('Invalid redirect location');
}
void response.body?.cancel().catch(() => {});
hopInit = nextHopInit(hopInit, currentUrl, nextUrl, status, options?.keepCredentialsOnRedirect);
currentUrl = nextUrl;
}
}
/**
* Returns an undici Agent whose connect.lookup is pinned to the already-validated
* addresses. This prevents DNS rebinding (TOCTOU) by ensuring the outbound
* connection goes to an address we checked, not a re-resolved one.
*
* Given the vetted list, in the order `resolveAll` put it, the socket gets
* every address on it at once and never one the guard left out: Node asks for
* `all: true` and walks the list itself, moving on after its attempt timeout
* when one family does not answer.
* A caller that still names one address gets exactly the old behaviour.
*/
export function createPinnedDispatcher(
resolved: string | readonly string[],
rejectUnauthorized = true,
responseTimeoutMs?: number,
): Agent {
const addresses = (typeof resolved === 'string' ? [resolved] : [...resolved]).map((address) => ({
address,
family: address.includes(':') ? 6 : 4,
}));
return new Agent({
// undici caps the wait for response headers at 5 minutes by default, and
// that cap is invisible from the call site: an AbortController set to
// fifteen still dies at five.
...(responseTimeoutMs
? { headersTimeout: responseTimeoutMs, bodyTimeout: responseTimeoutMs }
: {}),
connect: {
rejectUnauthorized,
lookup: (_hostname: string, opts: Record<string, unknown>, callback: Function) => {
// Node asks with `all: true` when it may choose between families itself.
if (opts?.all) {
callback(null, addresses);
} else {
callback(null, addresses[0].address, addresses[0].family);
}
},
},
});
}