Skip to content

Commit 486ef07

Browse files
committed
fix(security): block IPv4-compatible IPv6 SSRF bypass (#4467)
* fix(security): block IPv4-compatible IPv6 SSRF bypass * fix(security): also block IPv4-compatible IPv6 with Class E embedded IPv4 * fix(security): correct RFC1918 test label for IPv4-compat IPv6
1 parent 46d8774 commit 486ef07

2 files changed

Lines changed: 166 additions & 2 deletions

File tree

apps/sim/lib/core/security/input-validation.server.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export interface AsyncValidationResult extends ValidationResult {
2424
* - Octal notation (0177.0.0.1)
2525
* - Hex notation (0x7f000001)
2626
* - IPv4-mapped IPv6 (::ffff:127.0.0.1)
27+
* - IPv4-compatible IPv6 (::a.b.c.d / ::xxxx:xxxx, RFC 4291 §2.5.5.1, deprecated)
2728
* - Various edge cases that regex patterns miss
2829
*/
2930
export function isPrivateOrReservedIP(ip: string): boolean {
@@ -35,7 +36,26 @@ export function isPrivateOrReservedIP(ip: string): boolean {
3536
const addr = ipaddr.process(ip)
3637
const range = addr.range()
3738

38-
return range !== 'unicast'
39+
if (range !== 'unicast') {
40+
return true
41+
}
42+
43+
if (addr.kind() === 'ipv6') {
44+
const v6 = addr as ipaddr.IPv6
45+
const parts = v6.parts
46+
const firstSixZero = parts.slice(0, 6).every((p) => p === 0)
47+
if (firstSixZero) {
48+
const embedded = ipaddr.fromByteArray([
49+
(parts[6] >> 8) & 0xff,
50+
parts[6] & 0xff,
51+
(parts[7] >> 8) & 0xff,
52+
parts[7] & 0xff,
53+
])
54+
return embedded.range() !== 'unicast'
55+
}
56+
}
57+
58+
return false
3959
} catch {
4060
return true
4161
}

apps/sim/lib/core/security/input-validation.test.ts

Lines changed: 145 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@ import {
2626
validateSupabaseProjectId,
2727
validateWorkdayTenantUrl,
2828
} from '@/lib/core/security/input-validation'
29-
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
29+
import {
30+
isPrivateOrReservedIP,
31+
validateUrlWithDNS,
32+
} from '@/lib/core/security/input-validation.server'
3033
import { sanitizeForLogging } from '@/lib/core/security/redaction'
3134

3235
vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock)
@@ -562,6 +565,147 @@ describe('sanitizeForLogging', () => {
562565
})
563566
})
564567

568+
describe('isPrivateOrReservedIP', () => {
569+
describe('IPv4 private/reserved ranges', () => {
570+
it.concurrent.each([
571+
['192.168.1.1'],
572+
['192.168.0.0'],
573+
['10.0.0.1'],
574+
['10.255.255.255'],
575+
['172.16.0.1'],
576+
['172.31.255.255'],
577+
['127.0.0.1'],
578+
['127.255.255.255'],
579+
['169.254.169.254'],
580+
['0.0.0.0'],
581+
['224.0.0.1'],
582+
])('blocks IPv4 %s', (ip) => {
583+
expect(isPrivateOrReservedIP(ip)).toBe(true)
584+
})
585+
})
586+
587+
describe('IPv6 reserved ranges', () => {
588+
it.concurrent.each([
589+
['::1'],
590+
['::'],
591+
['fe80::1'],
592+
['fc00::1'],
593+
['fd00::1'],
594+
['ff02::1'],
595+
['2001:db8::1'],
596+
])('blocks IPv6 %s', (ip) => {
597+
expect(isPrivateOrReservedIP(ip)).toBe(true)
598+
})
599+
})
600+
601+
describe('IPv4-mapped IPv6 (::ffff:0:0/96)', () => {
602+
it.concurrent.each([
603+
['::ffff:192.168.1.1'],
604+
['::ffff:127.0.0.1'],
605+
['::ffff:169.254.169.254'],
606+
['::ffff:c0a8:101'],
607+
['::ffff:0:0'],
608+
])('blocks mapped private/reserved %s', (ip) => {
609+
expect(isPrivateOrReservedIP(ip)).toBe(true)
610+
})
611+
612+
it.concurrent('allows mapped public IPv4 ::ffff:8.8.8.8', () => {
613+
expect(isPrivateOrReservedIP('::ffff:8.8.8.8')).toBe(false)
614+
})
615+
})
616+
617+
describe('NAT64 (RFC 6052, 64:ff9b::/96)', () => {
618+
it.concurrent('blocks NAT64-encoded private IPv4', () => {
619+
expect(isPrivateOrReservedIP('64:ff9b::192.168.1.1')).toBe(true)
620+
})
621+
})
622+
623+
describe('IPv4-compatible IPv6 (::a.b.c.d, RFC 4291 §2.5.5.1, deprecated)', () => {
624+
it.concurrent.each([
625+
['::c0a8:101', '192.168.1.1 (URL-normalized hex form)'],
626+
['::c0a8:0101', '192.168.1.1 (zero-padded hex form)'],
627+
['::a9fe:a9fe', '169.254.169.254 (cloud metadata)'],
628+
['::7f00:1', '127.0.0.1 (loopback)'],
629+
['::7f00:0001', '127.0.0.1 (zero-padded)'],
630+
['::a00:1', '10.0.0.1 (RFC1918)'],
631+
['::ac10:1', '172.16.0.1 (RFC1918)'],
632+
['::e000:1', '224.0.0.1 (multicast)'],
633+
['::192.168.1.1', 'dotted form ::192.168.1.1'],
634+
['::169.254.169.254', 'dotted form ::169.254.169.254'],
635+
['::127.0.0.1', 'dotted form ::127.0.0.1'],
636+
['::10.0.0.1', 'dotted form ::10.0.0.1'],
637+
])('blocks %s — %s', (ip) => {
638+
expect(isPrivateOrReservedIP(ip)).toBe(true)
639+
})
640+
641+
it.concurrent.each([
642+
['::8.8.8.8', 'dotted form embedding public IPv4'],
643+
['::808:808', 'hex form embedding 8.8.8.8'],
644+
['::0808:0808', 'zero-padded hex form embedding 8.8.8.8'],
645+
])('allows IPv4-compatible IPv6 with embedded public IPv4 %s — %s', (ip) => {
646+
expect(isPrivateOrReservedIP(ip)).toBe(false)
647+
})
648+
649+
it.concurrent.each([
650+
['::ffff:1', 'embedded 255.255.0.1 (Class E reserved) via parts[6]=0xffff'],
651+
['::ffff:0', 'embedded 255.255.0.0 (Class E reserved)'],
652+
['::ffff:abcd', 'embedded 255.255.171.205 (Class E reserved)'],
653+
['::f000:1', 'embedded 240.0.0.1 (Class E reserved)'],
654+
])('blocks IPv4-compatible IPv6 with Class E embedded IPv4 %s — %s', (ip) => {
655+
expect(isPrivateOrReservedIP(ip)).toBe(true)
656+
})
657+
})
658+
659+
describe('non-IPv4-compat unicast IPv6 (must not over-block)', () => {
660+
it.concurrent.each([
661+
['2606:4700:4700::1111'],
662+
['2001:4860:4860::8888'],
663+
['::1:c0a8:101'],
664+
['1::c0a8:101'],
665+
['1:2:3:4:5:6:c0a8:101'],
666+
])('allows %s', (ip) => {
667+
expect(isPrivateOrReservedIP(ip)).toBe(false)
668+
})
669+
})
670+
671+
describe('IPv4 public addresses', () => {
672+
it.concurrent.each([['8.8.8.8'], ['1.1.1.1'], ['1.0.0.1']])('allows %s', (ip) => {
673+
expect(isPrivateOrReservedIP(ip)).toBe(false)
674+
})
675+
})
676+
677+
describe('IPv4 alternate notations', () => {
678+
it.concurrent.each([['0177.0.0.1'], ['0x7f000001']])('blocks loopback notation %s', (ip) => {
679+
expect(isPrivateOrReservedIP(ip)).toBe(true)
680+
})
681+
})
682+
683+
describe('invalid input', () => {
684+
it.concurrent.each([['not-an-ip'], [''], ['256.256.256.256'], ['::g']])('rejects %s', (ip) => {
685+
expect(isPrivateOrReservedIP(ip)).toBe(true)
686+
})
687+
})
688+
})
689+
690+
describe('URL hostname normalization (Node URL parser + isPrivateOrReservedIP integration)', () => {
691+
it.concurrent('Node normalizes [::192.168.1.1] to [::c0a8:101] and validator blocks it', () => {
692+
const url = new URL('http://[::192.168.1.1]/')
693+
const cleanHostname =
694+
url.hostname.startsWith('[') && url.hostname.endsWith(']')
695+
? url.hostname.slice(1, -1)
696+
: url.hostname
697+
expect(cleanHostname).toBe('::c0a8:101')
698+
expect(isPrivateOrReservedIP(cleanHostname)).toBe(true)
699+
})
700+
701+
it.concurrent('Node normalizes [::169.254.169.254] and validator blocks the metadata IP', () => {
702+
const url = new URL('http://[::169.254.169.254]/')
703+
const cleanHostname = url.hostname.slice(1, -1)
704+
expect(cleanHostname).toBe('::a9fe:a9fe')
705+
expect(isPrivateOrReservedIP(cleanHostname)).toBe(true)
706+
})
707+
})
708+
565709
describe('validateUrlWithDNS', () => {
566710
describe('basic validation', () => {
567711
it('should reject invalid URLs', async () => {

0 commit comments

Comments
 (0)