mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
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:
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);
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
|
||||
Reference in new issue
Block a user