fix(egress): check every redirect hop and close the reserved-range gaps

safeFetch with requirePublicHost vetted the first URL and pinned DNS
answers in the connector, but Node never looks up an IP literal, so a
followed redirect to a literal private or metadata address was dialled
unchecked. No caller follows redirects to a user-chosen host today; the
floor did not hold for the next one that would.

With requirePublicHost, redirects are now followed one hop at a time and
every location goes through isPublicUrl before it is dialled, at most five
hops. isPublicIp also refuses the benchmarking, protocol-assignment,
multicast, reserved and broadcast IPv4 ranges, site-local and multicast
IPv6 and the IPv4-translated spelling of a private address, and isPublicUrl
strips a trailing dot before the hostname denylist.
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 13:52:04 +01:00
1 parent e021adde40
commit 807d483b89
4 files changed
+174

No files matched your search

@@ -258,3 +258,55 @@ describe("webPushSubscriptionSchema SSRF guard (V3 audit)", () => {
expect(r.success).toBe(false);
});
});
/**
* Ranges the floor used to call public: benchmarking, IETF protocol
* assignments, multicast, reserved and broadcast IPv4; site-local and
* multicast IPv6; the IPv4-translated spelling of a private address; and a
* denylisted name written fully qualified with a trailing dot.
*/
describe("isPublicIp / isPublicUrl — reserved ranges and spellings", () => {
it("refuses the reserved IPv4 ranges", () => {
for (const ip of [
"198.18.0.1",
"198.19.255.254",
"192.0.0.1",
"224.0.0.1",
"239.255.255.250",
"240.0.0.1",
"255.255.255.255",
]) {
expect(isPublicIp(ip), ip).toBe(false);
}
for (const ip of ["198.17.0.1", "198.20.0.1", "192.0.1.1", "223.255.0.1"]) {
expect(isPublicIp(ip), ip).toBe(true);
}
});
it("refuses site-local, multicast and IPv4-translated IPv6", () => {
for (const ip of [
"fec0::1",
"feff::1",
"ff02::1",
"::ffff:0:7f00:1",
"::ffff:0:a9fe:a9fe",
]) {
expect(isPublicIp(ip), ip).toBe(false);
}
expect(isPublicIp("::ffff:0:808:808")).toBe(true);
});
it("refuses a denylisted name written with a trailing dot", () => {
for (const url of [
"http://localhost./",
"http://localhost../x",
"http://metadata.google.internal./",
"http://printer.local./",
"http://127.0.0.1./",
"http://[::ffff:0:7f00:1]/",
]) {
expect(isPublicUrl(url), url).toBe(false);
}
expect(isPublicUrl("https://example.com./")).toBe(true);
});
});
+18
View File
@@ -75,6 +75,9 @@ function isPrivateIpv4(ip: [number, number, number, number]): boolean {
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 RFC1918
if (a === 192 && b === 168) return true; // 192.168.0.0/16 RFC1918
if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT
if (a === 192 && b === 0 && ip[2] === 0) return true; // 192.0.0.0/24 IETF protocol assignments
if (a === 198 && (b === 18 || b === 19)) return true; // 198.18.0.0/15 benchmarking
if (a >= 224) return true; // 224/4 multicast, 240/4 reserved, broadcast
return false;
}
@@ -155,6 +158,14 @@ function embeddedIpv4(
return [bytes[12], bytes[13], bytes[14], bytes[15]];
}
// IPv4-translated (`::ffff:0:a.b.c.d`, RFC 2765).
if (
bytesEqual(bytes, 0, Array(8).fill(0)) &&
bytesEqual(bytes, 8, [0xff, 0xff, 0x00, 0x00])
) {
return [bytes[12], bytes[13], bytes[14], bytes[15]];
}
if (bytesEqual(bytes, 0, Array(12).fill(0))) {
return [bytes[12], bytes[13], bytes[14], bytes[15]];
}
@@ -219,6 +230,10 @@ export function isPublicIp(ip: string): boolean {
return false;
}
if (bytes[0] === 0xfe && (bytes[1] & 0xc0) === 0x80) return false;
// Site-local (fec0::/10, deprecated but still routed inside some
// networks) and multicast (ff00::/8).
if (bytes[0] === 0xfe && (bytes[1] & 0xc0) === 0xc0) return false;
if (bytes[0] === 0xff) return false;
if ((bytes[0] & 0xfe) === 0xfc) return false;
const embedded = embeddedIpv4(bytes);
@@ -320,6 +335,9 @@ export function isPublicUrl(url: string): boolean {
}
let h = parsed.hostname.toLowerCase();
if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
// A fully qualified name ends in a dot (`localhost.`, `foo.internal.`);
// the resolver treats it as the same name, so the denylist must too.
h = h.replace(/\.+$/, "");
// Hostname denylist (literal strings).
if (