fix: block SSRF via IPv4-mapped IPv6 webhook URLs (#2901) (#3166)

This commit is contained in:
Martin Glaser
2026-09-04 15:42:39 +10:00
committed by GitHub
parent 4aa3583e89
commit 2cac63a000
2 changed files with 31 additions and 10 deletions
@@ -81,13 +81,20 @@ describe('isPrivateUrl', () => {
expect(isPrivateUrl('http://[fd12::1]')).toBe(true);
});
it('should not catch IPv4-mapped IPv6 in URL form (URL parser normalizes to hex)', () => {
// new URL() normalizes "::ffff:127.0.0.1" to "::ffff:7f00:1" which none
// of the checks handle. This is fine because dns.lookup never returns
// IPv4-mapped addresses — it returns plain IPv4 (family: 4) instead.
expect(isPrivateUrl('http://[::ffff:127.0.0.1]')).toBe(false);
expect(isPrivateUrl('http://[::ffff:10.0.0.1]')).toBe(false);
it('should detect private IPv4-mapped IPv6 addresses (URL parser normalizes to hex)', () => {
// new URL() normalizes "::ffff:127.0.0.1" to the hex form "::ffff:7f00:1",
// so the embedded IPv4 must be decoded and re-checked. Otherwise a literal
// host such as http://[::ffff:127.0.0.1] bypasses every dotted-decimal
// check above (SSRF, see #2901).
expect(isPrivateUrl('http://[::ffff:127.0.0.1]')).toBe(true);
expect(isPrivateUrl('http://[::ffff:10.0.0.1]')).toBe(true);
expect(isPrivateUrl('http://[::ffff:192.168.0.1]')).toBe(true);
expect(isPrivateUrl('http://[::ffff:169.254.169.254]')).toBe(true);
});
it('should still allow public IPv4-mapped IPv6 addresses', () => {
expect(isPrivateUrl('http://[::ffff:8.8.8.8]')).toBe(false);
expect(isPrivateUrl('http://[::ffff:1.1.1.1]')).toBe(false);
});
});
@@ -69,11 +69,25 @@ export const isPrivateUrl = (url: string): boolean => {
}
}
// IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1)
const v4Mapped = normalizedHost.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
// IPv4-mapped IPv6, dotted form (e.g. ::ffff:127.0.0.1)
const v4MappedDotted = normalizedHost.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
if (v4Mapped) {
return isPrivateUrl(`http://${v4Mapped[1]}`);
if (v4MappedDotted) {
return isPrivateUrl(`http://${v4MappedDotted[1]}`);
}
// IPv4-mapped IPv6, hex form (e.g. ::ffff:7f00:1). `new URL()` normalizes the
// dotted form above to this, so it must be decoded to the embedded IPv4 as
// well - otherwise a literal host such as `http://[::ffff:127.0.0.1]` slips
// through every dotted-decimal check above (SSRF, see #2901).
const v4MappedHex = normalizedHost.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
if (v4MappedHex) {
const high = parseInt(v4MappedHex[1], 16);
const low = parseInt(v4MappedHex[2], 16);
const ipv4 = [high >> 8, high & 0xff, low >> 8, low & 0xff].join('.');
return isPrivateUrl(`http://${ipv4}`);
}
return false;