fix(security): harden SSRF IPv4 literal parsing

This commit is contained in:
Peter Steinberger
2026-02-19 15:14:22 +01:00
parent 3561442a9f
commit baa335f258
5 changed files with 106 additions and 4 deletions

View File

@@ -22,6 +22,17 @@ describe("fetchWithSsrFGuard hardening", () => {
expect(fetchImpl).not.toHaveBeenCalled();
});
it("blocks legacy loopback literal URLs before fetch", async () => {
const fetchImpl = vi.fn();
await expect(
fetchWithSsrFGuard({
url: "http://0177.0.0.1:8080/internal",
fetchImpl,
}),
).rejects.toThrow(/private|internal|blocked/i);
expect(fetchImpl).not.toHaveBeenCalled();
});
it("blocks redirect chains that hop to private hosts", async () => {
const lookupFn = vi.fn(async () => [
{ address: "93.184.216.34", family: 4 },

View File

@@ -122,6 +122,17 @@ describe("ssrf pinning", () => {
expect(lookup).not.toHaveBeenCalled();
});
it("blocks legacy loopback IPv4 literals before DNS lookup", async () => {
const lookup = vi.fn(async () => [
{ address: "93.184.216.34", family: 4 },
]) as unknown as LookupFn;
await expect(
resolvePinnedHostnameWithPolicy("0177.0.0.1", { lookupFn: lookup }),
).rejects.toThrow(SsrFBlockedError);
expect(lookup).not.toHaveBeenCalled();
});
it("allows ISATAP embedded private IPv4 when private network is explicitly enabled", async () => {
const lookup = vi.fn(async () => [
{ address: "2001:db8:1234::5efe:127.0.0.1", family: 6 },

View File

@@ -26,6 +26,12 @@ const privateIpCases = [
"fec0::1",
"2001:db8:1234::5efe:127.0.0.1",
"2001:db8:1234:1:200:5efe:7f00:1",
"0177.0.0.1",
"0x7f.0.0.1",
"127.1",
"2130706433",
"0x7f000001",
"017700000001",
];
const publicIpCases = [
@@ -38,9 +44,12 @@ const publicIpCases = [
"2001:0000:0:0:0:0:f7f7:f7f7",
"2001:db8:1234::5efe:8.8.8.8",
"2001:db8:1234:1:1111:5efe:7f00:1",
"8.8.2056",
"0x08080808",
];
const malformedIpv6Cases = ["::::", "2001:db8::gggg"];
const malformedIpv4Cases = ["08.0.0.1", "0x7g.0.0.1", "127.0.0.1.", "127..0.1"];
describe("ssrf ip classification", () => {
it.each(privateIpCases)("classifies %s as private", (address) => {
@@ -54,6 +63,10 @@ describe("ssrf ip classification", () => {
it.each(malformedIpv6Cases)("fails closed for malformed IPv6 %s", (address) => {
expect(isPrivateIpAddress(address)).toBe(true);
});
it.each(malformedIpv4Cases)("treats malformed IPv4 literal %s as non-IP", (address) => {
expect(isPrivateIpAddress(address)).toBe(false);
});
});
describe("normalizeFingerprint", () => {
@@ -74,4 +87,10 @@ describe("isBlockedHostnameOrIp", () => {
expect(isBlockedHostnameOrIp("2001:db8:1234::5efe:127.0.0.1")).toBe(true);
expect(isBlockedHostnameOrIp("2001:db8::1")).toBe(false);
});
it("blocks legacy IPv4 literal representations", () => {
expect(isBlockedHostnameOrIp("0177.0.0.1")).toBe(true);
expect(isBlockedHostnameOrIp("127.1")).toBe(true);
expect(isBlockedHostnameOrIp("2130706433")).toBe(true);
});
});

View File

@@ -70,14 +70,74 @@ function matchesHostnameAllowlist(hostname: string, allowlist: string[]): boolea
function parseIpv4(address: string): number[] | null {
const parts = address.split(".");
if (parts.length !== 4) {
if (parts.length < 1 || parts.length > 4) {
return null;
}
const numbers = parts.map((part) => Number.parseInt(part, 10));
if (numbers.some((value) => Number.isNaN(value) || value < 0 || value > 255)) {
const numbers: number[] = [];
for (const part of parts) {
if (!part) {
return null;
}
const lower = part.toLowerCase();
let value: number;
if (lower.startsWith("0x")) {
const hex = lower.slice(2);
if (!hex || !/^[0-9a-f]+$/i.test(hex)) {
return null;
}
value = Number.parseInt(hex, 16);
} else if (part.length > 1 && part.startsWith("0")) {
const octal = part.slice(1);
if (!/^[0-7]+$/.test(octal)) {
return null;
}
value = Number.parseInt(octal, 8);
} else {
if (!/^[0-9]+$/.test(part)) {
return null;
}
value = Number.parseInt(part, 10);
}
if (!Number.isFinite(value) || value < 0) {
return null;
}
numbers.push(value);
}
let ipv4Number: number;
if (numbers.length === 1) {
if (numbers[0] > 0xffffffff) {
return null;
}
ipv4Number = numbers[0];
} else if (numbers.length === 2) {
if (numbers[0] > 0xff || numbers[1] > 0xffffff) {
return null;
}
ipv4Number = numbers[0] * 0x1000000 + numbers[1];
} else if (numbers.length === 3) {
if (numbers[0] > 0xff || numbers[1] > 0xff || numbers[2] > 0xffff) {
return null;
}
ipv4Number = numbers[0] * 0x1000000 + numbers[1] * 0x10000 + numbers[2];
} else {
if (numbers.some((value) => value > 0xff)) {
return null;
}
ipv4Number = numbers[0] * 0x1000000 + numbers[1] * 0x10000 + numbers[2] * 0x100 + numbers[3];
}
if (!Number.isSafeInteger(ipv4Number) || ipv4Number < 0 || ipv4Number > 0xffffffff) {
return null;
}
return numbers;
return [
Math.floor(ipv4Number / 0x1000000) & 0xff,
Math.floor(ipv4Number / 0x10000) & 0xff,
Math.floor(ipv4Number / 0x100) & 0xff,
ipv4Number & 0xff,
];
}
function stripIpv6ZoneId(address: string): string {