mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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
This commit is contained in:
@@ -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) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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,147 @@ 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)'],
|
||||
['::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'],
|
||||
['::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)
|
||||
})
|
||||
|
||||
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)', () => {
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user