Skip to content

Commit 89d8121

Browse files
authored
fix: ipv6 classification (#6998)
## Summary - Extend `classifyBlockedIpv6` to handle IPv6 transition addresses that embed IPv4: NAT64 (`64:ff9b::/96`, `64:ff9b:1::/48`), 6to4 (`2002::/16`), and Teredo (`2001:0::/32`). - Reuse `hextetsToIpv4` / `classifyBlockedIpv4` for prefixes that carry an embedded IPv4; block the local-use NAT64 and Teredo ranges wholesale. - Add unit tests for classification, sync IP literals, and DNS resolution through the shared classifier. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/NangoHQ/nango/pull/6998?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
1 parent 79b4433 commit 89d8121

2 files changed

Lines changed: 69 additions & 0 deletions

File tree

packages/egress/lib/egress.unit.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,32 @@ describe('egress IP classification', () => {
6969
expect(classifyBlockedIp('fea0::1')).toBe('link_local');
7070
expect(classifyBlockedIp('feb0::1')).toBe('link_local');
7171
});
72+
73+
it('blocks NAT64 well-known prefix when the embedded IPv4 is blocked (RFC 6052)', () => {
74+
expect(classifyBlockedIp('64:ff9b::127.0.0.1')).toBe('loopback');
75+
expect(classifyBlockedIp('64:ff9b::7f00:1')).toBe('loopback');
76+
expect(classifyBlockedIp('64:ff9b::a9fe:a9fe')).toBe('link_local');
77+
expect(classifyBlockedIp('64:ff9b::a00:1')).toBe('private');
78+
});
79+
80+
it('blocks NAT64 local-use prefix wholesale (RFC 8215)', () => {
81+
expect(classifyBlockedIp('64:ff9b:1::1')).toBe('private');
82+
});
83+
84+
it('blocks 6to4 when the embedded IPv4 is blocked (RFC 3056)', () => {
85+
expect(classifyBlockedIp('2002:7f00:0001::')).toBe('loopback');
86+
expect(classifyBlockedIp('2002:a9fe:a9fe::')).toBe('link_local');
87+
});
88+
89+
it('blocks Teredo prefix wholesale (RFC 4380)', () => {
90+
expect(classifyBlockedIp('2001:0000:4136:e378:8000:63bf:56ff:fefe')).toBe('private');
91+
});
92+
93+
it('still classifies IPv4-mapped forms and allows public IPv6', () => {
94+
expect(classifyBlockedIp('::ffff:127.0.0.1')).toBe('loopback');
95+
expect(classifyBlockedIp('::ffff:169.254.169.254')).toBe('link_local');
96+
expect(classifyBlockedIp('2606:4700:4700::1111')).toBeNull();
97+
});
7298
});
7399

74100
describe('egress validateOutboundUrl', () => {
@@ -89,6 +115,14 @@ describe('egress validateOutboundUrl', () => {
89115
expect(result.ok).toBe(false);
90116
});
91117

118+
it('blocks IPv6 transition IP literals sync via shared classifier', () => {
119+
expect(validateOutboundUrlSync('http://[64:ff9b::127.0.0.1]/', policy).ok).toBe(false);
120+
expect(validateOutboundUrlSync('http://[64:ff9b::a9fe:a9fe]/', policy).ok).toBe(false);
121+
expect(validateOutboundUrlSync('http://[2002:7f00:1::]/', policy).ok).toBe(false);
122+
expect(validateOutboundUrlSync('http://[2001:0:4136:e378:8000:63bf:56ff:fefe]/', policy).ok).toBe(false);
123+
expect(validateOutboundUrlSync('http://[::ffff:169.254.169.254]/', policy).ok).toBe(false);
124+
});
125+
92126
it('allows public hostname sync', () => {
93127
const result = validateOutboundUrlSync('https://api.example.com/v1', policy);
94128
expect(result.ok).toBe(true);
@@ -102,6 +136,15 @@ describe('egress validateOutboundUrl', () => {
102136
expect(result.error.code).toBe('denied_dns');
103137
}
104138
});
139+
140+
it('blocks DNS rebinding to IPv6 transition addresses via shared classifier', async () => {
141+
vi.spyOn(dns, 'lookup').mockResolvedValue([{ address: '64:ff9b::a9fe:a9fe', family: 6 }] as never);
142+
const result = await validateOutboundUrlAsync('https://allowed.example.com/path', policy);
143+
expect(result.ok).toBe(false);
144+
if (!result.ok) {
145+
expect(result.error.code).toBe('denied_dns');
146+
}
147+
});
105148
});
106149

107150
describe('egress allowlist mode', () => {

packages/egress/lib/ip.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,32 @@ function classifyBlockedIpv6(ip: string): BlockedIpReason | null {
170170
}
171171
}
172172

173+
// NAT64 well-known prefix 64:ff9b::/96 (RFC 6052) and local-use 64:ff9b:1::/48 (RFC 8215)
174+
if (h0 === 0x64 && h1 === 0xff9b) {
175+
if (h2 === 1) {
176+
return 'private';
177+
}
178+
if (h2 === 0 && h3 === 0 && h4 === 0 && h5 === 0) {
179+
const nat64 = classifyBlockedIpv4(hextetsToIpv4(h6, h7));
180+
if (nat64) {
181+
return nat64;
182+
}
183+
}
184+
}
185+
186+
// 6to4 2002::/16 (RFC 3056): IPv4 is embedded in bits 16–47
187+
if (h0 === 0x2002) {
188+
const sixToFour = classifyBlockedIpv4(hextetsToIpv4(h1, h2));
189+
if (sixToFour) {
190+
return sixToFour;
191+
}
192+
}
193+
194+
// Teredo 2001:0000::/32 (RFC 4380)
195+
if (h0 === 0x2001 && h1 === 0) {
196+
return 'private';
197+
}
198+
173199
if ((h0 & 0xffc0) === 0xfe80) {
174200
return 'link_local';
175201
}

0 commit comments

Comments
 (0)