validate and canonicalize outbound request hosts

This commit is contained in:
Jarek Krochmalski committed 2026-08-27 07:34:38 +02:00
1 parent 0d516be4dd
commit d34a7733f7
2 files changed
+134 -26

No files matched your search

+49
View File
@@ -73,6 +73,55 @@ describe('ipCategory — fc/fd anchoring', () => {
});
});
describe('ipCategory — IPv6 transition address bypass (NAT64 / 6to4 / Teredo)', () => {
// new URL() normalizes/compresses a bracketed v6 host; these are the forms it emits.
const norm = (v6: string) => new URL(`http://[${v6}]`).hostname.replace(/^\[|\]$/g, '');
test('NAT64 WKP (64:ff9b::/96) embedded loopback/metadata/private is caught', () => {
expect(ipCategory(norm('64:ff9b::7f00:1'))).toBe('loopback'); // 127.0.0.1
expect(ipCategory(norm('64:ff9b::a9fe:a9fe'))).toBe('metadata'); // 169.254.169.254
expect(ipCategory(norm('64:ff9b::a00:1'))).toBe('private'); // 10.0.0.1
});
test('NAT64 local prefix (64:ff9b:1::/48) is blocked wholesale (reserved)', () => {
// The /48 embeds its v4 around the u-byte, not the last 32 bits, so the whole
// prefix is blocked outright rather than decoded - it has no legitimate use.
expect(ipCategory(norm('64:ff9b:1::7f00:1'))).toBe('reserved');
expect(ipCategory(norm('64:ff9b:1::a9fe:a9fe'))).toBe('reserved');
expect(ipCategory(norm('64:ff9b:1:a00:0:100::'))).toBe('reserved'); // RFC-6052 /48 layout
});
test('ISATAP interface-id (::0:5efe:v4 / ::200:5efe:v4, any prefix) is caught', () => {
expect(ipCategory(norm('::0:5efe:127.0.0.1'))).toBe('loopback');
expect(ipCategory(norm('::200:5efe:169.254.169.254'))).toBe('metadata');
expect(ipCategory(norm('::5efe:10.0.0.1'))).toBe('private');
// ISATAP is defined for any /64 prefix, including a global one.
expect(ipCategory(norm('2001:db8::5efe:10.0.0.1'))).toBe('private');
});
test('6to4 (2002::/16) embedded v4 is caught', () => {
expect(ipCategory(norm('2002:7f00:1::1'))).toBe('loopback'); // 127.0.0.1
expect(ipCategory(norm('2002:a9fe:a9fe::1'))).toBe('metadata'); // 169.254.169.254
expect(ipCategory(norm('2002:c0a8:1::1'))).toBe('private'); // 192.168.0.1
});
test('Teredo (2001:0000::/32) inverted client v4 is caught', () => {
// last two hextets are the client v4, bitwise-inverted: ~0x80ff.~0xfffe = 127.0.0.1
expect(ipCategory(norm('2001:0:ffff:ffff:ffff:ffff:80ff:fffe'))).toBe('loopback');
});
test('a transition prefix wrapping a PUBLIC v4 is NOT over-blocked', () => {
expect(ipCategory(norm('64:ff9b::8.8.8.8'))).toBe(null); // NAT64 WKP of 8.8.8.8
expect(ipCategory(norm('2002:0808:0808::1'))).toBe(null); // 6to4 of 8.8.8.8
expect(ipCategory(norm('::0:5efe:8.8.8.8'))).toBe(null); // ISATAP of 8.8.8.8
});
test('a genuine public IPv6 address is not misread as a transition address', () => {
expect(ipCategory(norm('2606:4700:4700::1111'))).toBe(null); // cloudflare
expect(ipCategory(norm('2001:4860:4860::8888'))).toBe(null); // google (2001 but not Teredo/ISATAP)
});
});
describe('validateRepositoryForSave — backup destination SSRF guard', () => {
test('blocks loopback and cloud-metadata repo hosts', () => {
expect(validateRepositoryForSave('rest:http://169.254.169.254/repo')).not.toBeNull();