diff --git a/packages/lib/server-only/webhooks/is-private-url.test.ts b/packages/lib/server-only/webhooks/is-private-url.test.ts index 841ed7938..d0c122818 100644 --- a/packages/lib/server-only/webhooks/is-private-url.test.ts +++ b/packages/lib/server-only/webhooks/is-private-url.test.ts @@ -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); }); }); diff --git a/packages/lib/server-only/webhooks/is-private-url.ts b/packages/lib/server-only/webhooks/is-private-url.ts index 38f531ee0..c75966792 100644 --- a/packages/lib/server-only/webhooks/is-private-url.ts +++ b/packages/lib/server-only/webhooks/is-private-url.ts @@ -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;