From 8b34eb201db7e8e794fde7e3821f1d8a67b65f3b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 5 May 2026 20:43:45 -0700 Subject: [PATCH 1/3] fix(security): block IPv4-compatible IPv6 SSRF bypass --- .../core/security/input-validation.server.ts | 22 ++- .../core/security/input-validation.test.ts | 137 +++++++++++++++++- 2 files changed, 157 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts index 5486e5991e3..a550d1ad3c8 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -24,6 +24,7 @@ export interface AsyncValidationResult extends ValidationResult { * - Octal notation (0177.0.0.1) * - Hex notation (0x7f000001) * - IPv4-mapped IPv6 (::ffff:127.0.0.1) + * - IPv4-compatible IPv6 (::a.b.c.d / ::xxxx:xxxx, RFC 4291 §2.5.5.1, deprecated) * - Various edge cases that regex patterns miss */ export function isPrivateOrReservedIP(ip: string): boolean { @@ -35,7 +36,26 @@ export function isPrivateOrReservedIP(ip: string): boolean { const addr = ipaddr.process(ip) const range = addr.range() - return range !== 'unicast' + if (range !== 'unicast') { + return true + } + + if (addr.kind() === 'ipv6') { + const v6 = addr as ipaddr.IPv6 + const parts = v6.parts + const firstSixZero = parts.slice(0, 6).every((p) => p === 0) + if (firstSixZero && parts[6] !== 0xffff) { + const embedded = ipaddr.fromByteArray([ + (parts[6] >> 8) & 0xff, + parts[6] & 0xff, + (parts[7] >> 8) & 0xff, + parts[7] & 0xff, + ]) + return embedded.range() !== 'unicast' + } + } + + return false } catch { return true } diff --git a/apps/sim/lib/core/security/input-validation.test.ts b/apps/sim/lib/core/security/input-validation.test.ts index 64bf5e33fd5..3d2391b3457 100644 --- a/apps/sim/lib/core/security/input-validation.test.ts +++ b/apps/sim/lib/core/security/input-validation.test.ts @@ -26,7 +26,10 @@ import { validateSupabaseProjectId, validateWorkdayTenantUrl, } from '@/lib/core/security/input-validation' -import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server' +import { + isPrivateOrReservedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' import { sanitizeForLogging } from '@/lib/core/security/redaction' vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) @@ -562,6 +565,138 @@ describe('sanitizeForLogging', () => { }) }) +describe('isPrivateOrReservedIP', () => { + describe('IPv4 private/reserved ranges', () => { + it.concurrent.each([ + ['192.168.1.1'], + ['192.168.0.0'], + ['10.0.0.1'], + ['10.255.255.255'], + ['172.16.0.1'], + ['172.31.255.255'], + ['127.0.0.1'], + ['127.255.255.255'], + ['169.254.169.254'], + ['0.0.0.0'], + ['224.0.0.1'], + ])('blocks IPv4 %s', (ip) => { + expect(isPrivateOrReservedIP(ip)).toBe(true) + }) + }) + + describe('IPv6 reserved ranges', () => { + it.concurrent.each([ + ['::1'], + ['::'], + ['fe80::1'], + ['fc00::1'], + ['fd00::1'], + ['ff02::1'], + ['2001:db8::1'], + ])('blocks IPv6 %s', (ip) => { + expect(isPrivateOrReservedIP(ip)).toBe(true) + }) + }) + + describe('IPv4-mapped IPv6 (::ffff:0:0/96)', () => { + it.concurrent.each([ + ['::ffff:192.168.1.1'], + ['::ffff:127.0.0.1'], + ['::ffff:169.254.169.254'], + ['::ffff:c0a8:101'], + ['::ffff:0:0'], + ])('blocks mapped private/reserved %s', (ip) => { + expect(isPrivateOrReservedIP(ip)).toBe(true) + }) + + it.concurrent('allows mapped public IPv4 ::ffff:8.8.8.8', () => { + expect(isPrivateOrReservedIP('::ffff:8.8.8.8')).toBe(false) + }) + }) + + describe('NAT64 (RFC 6052, 64:ff9b::/96)', () => { + it.concurrent('blocks NAT64-encoded private IPv4', () => { + expect(isPrivateOrReservedIP('64:ff9b::192.168.1.1')).toBe(true) + }) + }) + + describe('IPv4-compatible IPv6 (::a.b.c.d, RFC 4291 §2.5.5.1, deprecated)', () => { + it.concurrent.each([ + ['::c0a8:101', '192.168.1.1 (URL-normalized hex form)'], + ['::c0a8:0101', '192.168.1.1 (zero-padded hex form)'], + ['::a9fe:a9fe', '169.254.169.254 (cloud metadata)'], + ['::7f00:1', '127.0.0.1 (loopback)'], + ['::7f00:0001', '127.0.0.1 (zero-padded)'], + ['::a:0', '10.0.0.0 (RFC1918)'], + ['::ac10:1', '172.16.0.1 (RFC1918)'], + ['::e000:1', '224.0.0.1 (multicast)'], + ['::192.168.1.1', 'dotted form ::192.168.1.1'], + ['::169.254.169.254', 'dotted form ::169.254.169.254'], + ['::127.0.0.1', 'dotted form ::127.0.0.1'], + ['::10.0.0.1', 'dotted form ::10.0.0.1'], + ])('blocks %s — %s', (ip) => { + expect(isPrivateOrReservedIP(ip)).toBe(true) + }) + + it.concurrent.each([ + ['::8.8.8.8', 'dotted form embedding public IPv4'], + ['::808:808', 'hex form embedding 8.8.8.8'], + ['::0808:0808', 'zero-padded hex form embedding 8.8.8.8'], + ])('allows IPv4-compatible IPv6 with embedded public IPv4 %s — %s', (ip) => { + expect(isPrivateOrReservedIP(ip)).toBe(false) + }) + }) + + describe('non-IPv4-compat unicast IPv6 (must not over-block)', () => { + it.concurrent.each([ + ['2606:4700:4700::1111'], + ['2001:4860:4860::8888'], + ['::1:c0a8:101'], + ['1::c0a8:101'], + ['1:2:3:4:5:6:c0a8:101'], + ])('allows %s', (ip) => { + expect(isPrivateOrReservedIP(ip)).toBe(false) + }) + }) + + describe('IPv4 public addresses', () => { + it.concurrent.each([['8.8.8.8'], ['1.1.1.1'], ['1.0.0.1']])('allows %s', (ip) => { + expect(isPrivateOrReservedIP(ip)).toBe(false) + }) + }) + + describe('IPv4 alternate notations', () => { + it.concurrent.each([['0177.0.0.1'], ['0x7f000001']])('blocks loopback notation %s', (ip) => { + expect(isPrivateOrReservedIP(ip)).toBe(true) + }) + }) + + describe('invalid input', () => { + it.concurrent.each([['not-an-ip'], [''], ['256.256.256.256'], ['::g']])('rejects %s', (ip) => { + expect(isPrivateOrReservedIP(ip)).toBe(true) + }) + }) +}) + +describe('URL hostname normalization (Node URL parser + isPrivateOrReservedIP integration)', () => { + it.concurrent('Node normalizes [::192.168.1.1] to [::c0a8:101] and validator blocks it', () => { + const url = new URL('http://[::192.168.1.1]/') + const cleanHostname = + url.hostname.startsWith('[') && url.hostname.endsWith(']') + ? url.hostname.slice(1, -1) + : url.hostname + expect(cleanHostname).toBe('::c0a8:101') + expect(isPrivateOrReservedIP(cleanHostname)).toBe(true) + }) + + it.concurrent('Node normalizes [::169.254.169.254] and validator blocks the metadata IP', () => { + const url = new URL('http://[::169.254.169.254]/') + const cleanHostname = url.hostname.slice(1, -1) + expect(cleanHostname).toBe('::a9fe:a9fe') + expect(isPrivateOrReservedIP(cleanHostname)).toBe(true) + }) +}) + describe('validateUrlWithDNS', () => { describe('basic validation', () => { it('should reject invalid URLs', async () => { From 91b64775a67028e9d191f7c697fd904ea7e35aba Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 5 May 2026 20:47:30 -0700 Subject: [PATCH 2/3] fix(security): also block IPv4-compatible IPv6 with Class E embedded IPv4 --- apps/sim/lib/core/security/input-validation.server.ts | 2 +- apps/sim/lib/core/security/input-validation.test.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts index a550d1ad3c8..ed23140ea46 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -44,7 +44,7 @@ export function isPrivateOrReservedIP(ip: string): boolean { const v6 = addr as ipaddr.IPv6 const parts = v6.parts const firstSixZero = parts.slice(0, 6).every((p) => p === 0) - if (firstSixZero && parts[6] !== 0xffff) { + if (firstSixZero) { const embedded = ipaddr.fromByteArray([ (parts[6] >> 8) & 0xff, parts[6] & 0xff, diff --git a/apps/sim/lib/core/security/input-validation.test.ts b/apps/sim/lib/core/security/input-validation.test.ts index 3d2391b3457..2179c029c52 100644 --- a/apps/sim/lib/core/security/input-validation.test.ts +++ b/apps/sim/lib/core/security/input-validation.test.ts @@ -645,6 +645,15 @@ describe('isPrivateOrReservedIP', () => { ])('allows IPv4-compatible IPv6 with embedded public IPv4 %s — %s', (ip) => { expect(isPrivateOrReservedIP(ip)).toBe(false) }) + + it.concurrent.each([ + ['::ffff:1', 'embedded 255.255.0.1 (Class E reserved) via parts[6]=0xffff'], + ['::ffff:0', 'embedded 255.255.0.0 (Class E reserved)'], + ['::ffff:abcd', 'embedded 255.255.171.205 (Class E reserved)'], + ['::f000:1', 'embedded 240.0.0.1 (Class E reserved)'], + ])('blocks IPv4-compatible IPv6 with Class E embedded IPv4 %s — %s', (ip) => { + expect(isPrivateOrReservedIP(ip)).toBe(true) + }) }) describe('non-IPv4-compat unicast IPv6 (must not over-block)', () => { From 031ef683cf8bc5b79181e8b175eee3e54e50f71c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 5 May 2026 20:50:19 -0700 Subject: [PATCH 3/3] fix(security): correct RFC1918 test label for IPv4-compat IPv6 --- apps/sim/lib/core/security/input-validation.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/lib/core/security/input-validation.test.ts b/apps/sim/lib/core/security/input-validation.test.ts index 2179c029c52..55b07a72db0 100644 --- a/apps/sim/lib/core/security/input-validation.test.ts +++ b/apps/sim/lib/core/security/input-validation.test.ts @@ -627,7 +627,7 @@ describe('isPrivateOrReservedIP', () => { ['::a9fe:a9fe', '169.254.169.254 (cloud metadata)'], ['::7f00:1', '127.0.0.1 (loopback)'], ['::7f00:0001', '127.0.0.1 (zero-padded)'], - ['::a:0', '10.0.0.0 (RFC1918)'], + ['::a00:1', '10.0.0.1 (RFC1918)'], ['::ac10:1', '172.16.0.1 (RFC1918)'], ['::e000:1', '224.0.0.1 (multicast)'], ['::192.168.1.1', 'dotted form ::192.168.1.1'],