inspector/server/src/proxy-security.ts
cliffhall 923c58edec fix(server): pin all validated addresses; keep tests out of the build
Addresses the round-2 Copilot review (both suppressed comments).

- createPinnedAgent now takes the whole validated address list instead of
  a single IP, so Node's address-family fallback still works: a
  dual-stack host whose first address is unreachable (IPv6 on an
  IPv4-only network) connects via a later one. The set net.connect may
  choose from is exactly the prevalidated set, so the TOCTOU guarantee is
  unchanged. Empty input throws ProxyTargetError rather than pinning
  undefined.
- Tests: dual-stack pass-through, the empty-list guard, and an
  end-to-end request where the first pinned address is a black hole
  (2001:db8::1) and the connection must complete via the second.
- server/tsconfig.json excludes src/**/__tests__/** so the suite is no
  longer emitted into build/ and published with the package (verified:
  build/__tests__ is gone). tsconfig.test.json keeps the suite
  type-checked, and `npm test` runs it before vitest.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-19 17:05:42 -04:00

183 lines
6.4 KiB
TypeScript

/**
* SSRF-prevention helpers for the /fetch reverse proxy.
*
* Extracted from index.ts so they can be unit-tested independently.
*
* ## DNS-rebinding / TOCTOU threat model
*
* `assertSafeProxyTarget` resolves the target hostname and validates every
* returned IP against the block-list. The resolved IPs are returned to the
* caller so they can be passed to `createPinnedAgent`, which builds an
* http/https.Agent whose `lookup` callback unconditionally returns the
* pre-validated IP. The agent is then passed to `node-fetch`, which calls the
* lookup hook instead of doing its own DNS query.
*
* Without this pinning there is a TOCTOU race: an attacker who controls the
* target domain can flip its DNS record to 169.254.169.254 (AWS/GCP/Azure
* instance-metadata) in the window between `assertSafeProxyTarget` returning
* and `fetch` performing its own resolution. Pinning eliminates that window.
*/
import http from "node:http";
import https from "node:https";
import { isIP } from "node:net";
import type dns from "node:dns";
import { lookup as dnsLookup } from "node:dns/promises";
// ---------------------------------------------------------------------------
// Block-list
// ---------------------------------------------------------------------------
/**
* Returns true if `ip` is a link-local or cloud-metadata address that must
* never be reachable through the proxy.
*
* Blocked ranges:
* - 169.254.0.0/16 (IPv4 link-local; includes 169.254.169.254 IMDS)
* - ::ffff:169.254.x.x (IPv4-mapped IPv6 variants of the above)
* - fe80::/10 (IPv6 link-local)
* - fd00:ec2::254 (AWS IPv6 instance-metadata)
*/
export function isBlockedProxyAddress(ip: string): boolean {
const kind = isIP(ip);
if (kind === 4) {
const parts = ip.split(".").map(Number);
return parts[0] === 169 && parts[1] === 254;
}
if (kind === 6) {
const addr = ip.toLowerCase();
// IPv4-mapped IPv6 — two serialization forms:
// dotted ::ffff:169.254.169.254
// hex ::ffff:a9fe:a9fe
const mapped = addr.match(/^::ffff:(.+)$/);
if (mapped) {
const tail = mapped[1];
if (isIP(tail) === 4) {
return isBlockedProxyAddress(tail);
}
const hex = tail.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
if (hex) {
const high = parseInt(hex[1], 16);
const low = parseInt(hex[2], 16);
return isBlockedProxyAddress(
`${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`,
);
}
}
if (/^fe[89ab]/.test(addr)) return true; // fe80::/10
return addr === "fd00:ec2::254"; // AWS IPv6 IMDS
}
return false;
}
// ---------------------------------------------------------------------------
// DNS validation
// ---------------------------------------------------------------------------
/** Raised when the /fetch proxy is asked to reach a disallowed target. */
export class ProxyTargetError extends Error {}
/**
* Resolves `parsedUrl`'s hostname and asserts that every returned IP is
* outside the block-list.
*
* Returns the list of validated IP strings so the caller can pin one of them
* into an HTTP agent (see `createPinnedAgent`), eliminating the TOCTOU window
* between this check and the actual `fetch()` call.
*
* @throws {ProxyTargetError} if the host cannot be resolved or any resolved
* address falls inside the block-list.
*/
export async function assertSafeProxyTarget(parsedUrl: URL): Promise<string[]> {
const host = parsedUrl.hostname.replace(/^\[|\]$/g, ""); // strip IPv6 brackets
let addresses: string[];
if (isIP(host)) {
addresses = [host];
} else {
try {
addresses = (await dnsLookup(host, { all: true })).map((r) => r.address);
} catch {
throw new ProxyTargetError(`Could not resolve host: ${host}`);
}
}
if (addresses.length === 0) {
throw new ProxyTargetError(`Could not resolve host: ${host}`);
}
for (const address of addresses) {
if (isBlockedProxyAddress(address)) {
throw new ProxyTargetError(
`Refusing to proxy request to blocked address (${address})`,
);
}
}
return addresses;
}
// ---------------------------------------------------------------------------
// IP-pinned agent factory
// ---------------------------------------------------------------------------
/**
* Creates an `http.Agent` or `https.Agent` whose internal `lookup` hook always
* returns `pinnedIps`, bypassing the OS resolver entirely.
*
* Pass this agent to `node-fetch` on every hop of `safeProxyFetch` so the TCP
* connection always goes to an IP that was validated by `assertSafeProxyTarget`
* — even if the domain's DNS record changes mid-flight.
*
* Every address `assertSafeProxyTarget` returned is handed to the hook, not
* just the first, so Node keeps its normal address-family fallback: a
* dual-stack host whose first address is unreachable (IPv6 on an IPv4-only
* network, say) still connects via a later one. The set it may choose from is
* exactly the prevalidated set, so the TOCTOU guarantee is unchanged.
*
* @param protocol - `"http:"` or `"https:"` (from `URL.protocol`)
* @param pinnedIps - Validated IPv4/IPv6 address strings, in preference order
*/
export function createPinnedAgent(
protocol: string,
pinnedIps: string[],
): http.Agent | https.Agent {
if (pinnedIps.length === 0) {
throw new ProxyTargetError("No validated address to connect to");
}
const resolved: dns.LookupAddress[] = pinnedIps.map((address) => ({
address,
family: isIP(address) === 6 ? 6 : 4,
}));
// Node.js lookup signature: (hostname, options, callback).
//
// The callback shape depends on `options.all`. Node's `net.connect` runs with
// `autoSelectFamily` enabled by default (Node >= 20), so it calls the hook
// with `{ all: true }` and expects an ARRAY of `{ address, family }` — handing
// it the scalar form there fails the connection with ERR_INVALID_IP_ADDRESS.
// Both shapes are supported so the agent works on either call path.
const lookup = (
_hostname: string,
opts: dns.LookupOptions | undefined,
callback: (
err: NodeJS.ErrnoException | null,
address: string | dns.LookupAddress[],
family?: number,
) => void,
): void => {
if (opts?.all) {
callback(null, resolved);
return;
}
callback(null, resolved[0].address, resolved[0].family);
};
if (protocol === "https:") {
return new https.Agent({ lookup } as https.AgentOptions);
}
return new http.Agent({ lookup } as http.AgentOptions);
}