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

+85 -26
View File
@@ -59,35 +59,89 @@ export function isSafeNotificationUrl(raw: string): { ok: boolean; reason?: stri
export type IpCategory = 'loopback' | 'metadata' | 'private' | 'reserved';
/**
* Extract the embedded IPv4 dotted-quad from an IPv4-mapped/compatible IPv6 host,
* or null. `new URL()` normalizes `::ffff:1.2.3.4` to the HEX form `::ffff:102:304`,
* so a regex on the dotted form alone is dead on the real code path — every mapped
* address must be canonicalized from its hextets. Handles the compressed
* `::ffff:HHHH:HHHH`, the fully-expanded `0:0:0:0:0:ffff:HHHH:HHHH`, the
* `::ffff:0:HHHH:HHHH` (v4-translated), and the deprecated IPv4-compatible
* `::HHHH:HHHH` / `::a.b.c.d`. The last two 16-bit hextets carry the 32-bit v4.
* Expand an IPv6 literal to exactly 8 numeric hextets, or null if it is not a
* parseable IPv6 address. `new URL()` normalizes and compresses (`::`) v6 hosts, so
* embedded-v4 extraction must work off the FULL hextet vector, not the compressed
* text. A trailing dotted quad (`::ffff:1.2.3.4`) is folded into its two hextets.
*/
function expandV6(h: string): number[] | null {
if (!h.includes(':')) return null;
let s = h;
// Fold a trailing dotted IPv4 (v4-mapped/compatible) into two hextets first.
const dotted = s.match(/^(.*:)(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (dotted) {
const [a, b, c, d] = [Number(dotted[2]), Number(dotted[3]), Number(dotted[4]), Number(dotted[5])];
if ([a, b, c, d].some((n) => n > 255)) return null;
s = `${dotted[1]}${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`;
}
const halves = s.split('::');
if (halves.length > 2) return null; // more than one "::" is invalid
const parse = (part: string): number[] | null => {
if (part === '') return [];
const out: number[] = [];
for (const g of part.split(':')) {
if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
out.push(parseInt(g, 16));
}
return out;
};
const head = parse(halves[0]);
const tail = halves.length === 2 ? parse(halves[1]) : [];
if (head === null || tail === null) return null;
if (halves.length === 2) {
const fill = 8 - head.length - tail.length;
if (fill < 0) return null;
return [...head, ...new Array(fill).fill(0), ...tail];
}
return head.length === 8 ? head : null;
}
/** Two 16-bit hextets -> a dotted-quad IPv4 string. */
function hextetsToV4(hi: number, lo: number): string {
return `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
}
/**
* Extract the embedded IPv4 dotted-quad from an IPv4-mapped/compatible IPv6 host OR
* an IPv6 transition address (NAT64, 6to4, Teredo), or null. These transition
* mechanisms carry a real IPv4 destination inside the v6 literal, so an SSRF guard
* that only classifies the v6 prefix would wave through a loopback/metadata/private
* target encoded this way. The embedded v4 is judged through the same range checks.
*/
function embeddedV4(h: string): string | null {
// Trailing dotted quad after a v4-mapped/compatible prefix, in either the
// compressed (::ffff:1.2.3.4) or fully-expanded (0:0:0:0:0:ffff:1.2.3.4) form.
const dotted = h.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
if (dotted && /^(::ffff:|::ffff:0:|::|(0:){5}ffff:|(0:){4}0:ffff:)/.test(h)) {
return dotted[1];
const x = expandV6(h);
if (!x) return null;
// v4-mapped ::ffff:a.b.c.d (x = 0:0:0:0:0:ffff:HH:LL) and v4-translated
// ::ffff:0:a.b.c.d, plus the deprecated IPv4-compatible ::a.b.c.d. In every case
// the last two hextets hold the 32-bit v4.
const mappedOrCompat =
(x[0] === 0 && x[1] === 0 && x[2] === 0 && x[3] === 0 && x[4] === 0 && x[5] === 0xffff) || // ::ffff:v4
(x[0] === 0 && x[1] === 0 && x[2] === 0 && x[3] === 0 && x[4] === 0xffff && x[5] === 0) || // ::ffff:0:v4
(x[0] === 0 && x[1] === 0 && x[2] === 0 && x[3] === 0 && x[4] === 0 && x[5] === 0); // ::v4 (compat)
if (mappedOrCompat) return hextetsToV4(x[6], x[7]);
// NAT64 WKP: 64:ff9b::/96 (RFC 6052) - v4 in the last two hextets.
if (x[0] === 0x64 && x[1] === 0xff9b && x[2] === 0 && x[3] === 0 && x[4] === 0 && x[5] === 0) {
return hextetsToV4(x[6], x[7]);
}
// Hex-hextet v4-mapped/compatible forms. Take the last two colon-separated
// groups as the 32-bit v4 IFF the address is one of the embedded-v4 shapes.
const isMapped =
/^::ffff:[0-9a-f]{1,4}:[0-9a-f]{1,4}$/.test(h) ||
/^::ffff:0:[0-9a-f]{1,4}:[0-9a-f]{1,4}$/.test(h) ||
/^(0:){5}ffff:[0-9a-f]{1,4}:[0-9a-f]{1,4}$/.test(h) ||
/^::[0-9a-f]{1,4}:[0-9a-f]{1,4}$/.test(h); // deprecated v4-compatible ::a:b
if (!isMapped) return null;
const groups = h.split(':').filter((g) => g.length > 0);
if (groups.length < 2) return null;
const hi = parseInt(groups[groups.length - 2], 16);
const lo = parseInt(groups[groups.length - 1], 16);
if (Number.isNaN(hi) || Number.isNaN(lo)) return null;
return `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
// 6to4: 2002:V4HI:V4LO::/48 (RFC 3056) - the v4 is hextets 1 and 2.
if (x[0] === 0x2002) return hextetsToV4(x[1], x[2]);
// Teredo: 2001:0000::/32 (RFC 4380) - the client v4 is the LAST two hextets,
// bitwise-inverted (obfuscated).
if (x[0] === 0x2001 && x[1] === 0) return hextetsToV4(~x[6] & 0xffff, ~x[7] & 0xffff);
// ISATAP (RFC 5214): the interface id ...:0:5efe:V4 (global-scope) or ...:200:5efe:V4
// (private-scope EUI) carries the v4 in the last two hextets, under ANY /64 prefix.
// Deliberately prefix-agnostic (ISATAP is not scoped to a prefix): a genuine public
// address whose IID happens to be `0:5efe:<v4>` is decoded too and may over-block if
// that v4 is private - the fail-SAFE direction (it only ever adds a block, never
// removes one), and vanishingly rare, so accepted.
if (x[5] === 0x5efe && (x[4] === 0 || x[4] === 0x0200)) return hextetsToV4(x[6], x[7]);
return null;
}
export function ipCategory(host: string): IpCategory | null {
@@ -98,6 +152,11 @@ export function ipCategory(host: string): IpCategory | null {
// Require a colon so real IPv6 unique-local/link-local literals match but public
// DNS names starting "fc"/"fd" (fcm.googleapis.com, fdroid.link) do not.
if (h.startsWith('fe80:') || /^f[cd][0-9a-f]{0,2}:/.test(h)) return 'private';
// NAT64 local prefix 64:ff9b:1::/48 (RFC 8215) embeds its v4 around the u-byte
// (RFC 6052 2.2), not in the last 32 bits - awkward to decode and with no
// legitimate use as a user-supplied target, so block the whole prefix outright.
const x6 = expandV6(h);
if (x6 && x6[0] === 0x64 && x6[1] === 0xff9b && x6[2] === 1) return 'reserved';
// IPv4-mapped/compatible IPv6 — judge the embedded v4 (see embeddedV4).
const v4 = embeddedV4(h) ?? h;
const m = v4.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
+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();