Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions lib/src/host/iframe-proxy-rewrite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,31 @@ describe('isBlockedAddress', () => {
expect(isBlockedAddress('169.254.0.1')).toBe(true);
expect(isBlockedAddress('fe80::1')).toBe(true);
});
it('blocks the metadata endpoint under equivalent IPv4 encodings', () => {
// Defense-in-depth: for http: upstreams the WHATWG URL parser already
// collapses these to 169.254.169.254 before the guard sees them, so the
// guard must not rely on that pre-normalization — it canonicalizes them
// itself.
expect(isBlockedAddress('2852039166')).toBe(true); // 32-bit decimal
expect(isBlockedAddress('0xa9fea9fe')).toBe(true); // 32-bit hex
expect(isBlockedAddress('0xA9.0xFE.0xA9.0xFE')).toBe(true); // dotted hex
expect(isBlockedAddress('0251.0376.0251.0376')).toBe(true); // dotted octal
expect(isBlockedAddress('169.254.43518')).toBe(true); // short form
});
it('blocks the metadata endpoint wrapped in IPv6', () => {
expect(isBlockedAddress('::ffff:169.254.169.254')).toBe(true); // mapped, dotted
expect(isBlockedAddress('::ffff:a9fe:a9fe')).toBe(true); // mapped, hex
expect(isBlockedAddress('[::ffff:169.254.169.254]')).toBe(true); // bracketed
expect(isBlockedAddress('::169.254.169.254')).toBe(true); // compat form
});
it('allows ordinary hosts', () => {
expect(isBlockedAddress('127.0.0.1')).toBe(false);
expect(isBlockedAddress('localhost')).toBe(false);
expect(isBlockedAddress('example.com')).toBe(false);
expect(isBlockedAddress('::1')).toBe(false); // loopback
expect(isBlockedAddress('10.0.0.1')).toBe(false); // private, trusted
expect(isBlockedAddress('169.255.0.1')).toBe(false); // just outside the /16
expect(isBlockedAddress('2852094976')).toBe(false); // 169.255.0.0 as decimal
});
});

Expand Down
68 changes: 65 additions & 3 deletions lib/src/host/iframe-proxy-rewrite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,13 +116,75 @@ export function instrumentHtml(body: string): string {
return shimTag + html;
}

// 169.254.0.0/16 — IPv4 link-local, incl. the 169.254.169.254 cloud-metadata
// endpoint — as a numeric range so every equivalent encoding is caught.
const LINK_LOCAL_V4_START = 0xa9fe0000; // 169.254.0.0
const LINK_LOCAL_V4_END = 0xa9feffff; // 169.254.255.255

// Parse one dotted-quad component with inet_aton semantics: hex (0x…), octal
// (leading 0), or decimal. Returns null for anything else.
function parseIPv4Part(part: string): number | null {
if (/^0x[0-9a-f]+$/.test(part)) return parseInt(part.slice(2), 16);
if (/^0[0-7]+$/.test(part)) return parseInt(part, 8);
if (/^(0|[1-9][0-9]*)$/.test(part)) return parseInt(part, 10);
return null;
}

// Parse a hostname as an IPv4 literal the way the OS resolver (getaddrinfo /
// inet_aton) would — including short forms and non-decimal encodings — so that
// 2852039166, 0xA9FEA9FE, 0251.0376.0251.0376 and 169.254.169.254 all collapse
// to the same 32-bit value. Returns null when the string isn't a numeric IPv4.
function parseIPv4(host: string): number | null {
const parts = host.split('.');
if (parts.length === 0 || parts.length > 4) return null;
const nums: number[] = [];
for (const part of parts) {
const n = parseIPv4Part(part);
if (n === null) return null;
nums.push(n);
}
// Every part but the last is a single byte; the last fills the remainder.
for (let i = 0; i < nums.length - 1; i++) {
if (nums[i] > 0xff) return null;
}
const last = nums[nums.length - 1];
if (last > Math.pow(256, 5 - nums.length) - 1) return null;
let value = last;
for (let i = 0; i < nums.length - 1; i++) {
value += nums[i] * Math.pow(256, 3 - i);
}
return value >>> 0 === value ? value : null;
}

// Extract the 32-bit IPv4 address embedded in an IPv4-mapped or IPv4-compatible
// IPv6 literal (::ffff:169.254.169.254, ::ffff:a9fe:a9fe, ::169.254.169.254),
// or null if this isn't such an address.
function embeddedIPv4(h: string): number | null {
const m = h.match(/^::(?:ffff:)?(.+)$/);
if (!m) return null;
const tail = m[1];
if (tail.includes('.')) return parseIPv4(tail.slice(tail.lastIndexOf(':') + 1));
const groups = tail.split(':');
if (groups.length === 2 && groups.every((g) => /^[0-9a-f]{1,4}$/.test(g))) {
return ((parseInt(groups[0], 16) << 16) >>> 0) + parseInt(groups[1], 16);
}
return null;
}

export function isBlockedAddress(hostname: string): boolean {
const h = hostname.replace(/^\[|\]$/g, '').toLowerCase();
// IPv4 link-local / cloud metadata (169.254.0.0/16, incl. 169.254.169.254).
if (/^169\.254\./.test(h)) return true;
// IPv6 link-local (fe80::/10).
if (/^fe[89ab][0-9a-f]:/.test(h)) return true;
return false;
// Resolve the host to its 32-bit IPv4 value across every equivalent encoding
// (decimal/octal/hex, short forms, IPv4-mapped IPv6) and range-check the
// link-local / cloud-metadata block. The genuine end-to-end hole a literal
// 169.254.* match leaves is the IPv6-embedded form (::ffff:169.254.169.254),
// which the WHATWG URL parser normalizes to a hex-group spelling the regex
// can't see; the numeric-IPv4 spellings are collapsed by that same parser
// before the guard runs, so canonicalizing them here is defense-in-depth
// against callers that don't pre-normalize rather than a live bypass fix.
const v4 = h.includes(':') ? embeddedIPv4(h) : parseIPv4(h);
return v4 !== null && v4 >= LINK_LOCAL_V4_START && v4 <= LINK_LOCAL_V4_END;
}

// --- Served error / diagnostic pages ----------------------------------------
Expand Down
Loading