From 6896f8b42904ab91c5c54ca5b194f83370e31171 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 10 May 2026 11:06:43 -0700 Subject: [PATCH] fix(browse): block direct IPv6 link-local navigation URL validation centralises link-local (fe80::/10) into BLOCKED_IPV6_PREFIXES alongside ULA (fc00::/7), so direct `http://[fe80::N]/` URLs are rejected the same way `http://[fc00::]/` already was. Previously the link-local guard only fired during DNS AAAA resolution, leaving direct-literal URLs to slip through. Prefix range covers fe80::-febf::: ['fe8','fe9','fea','feb']. Regression test: validateNavigationUrl('http://[fe80::2]/') now throws with /cloud metadata/i. Contributed by @hiSandog (#1249). Co-Authored-By: Claude Opus 4.7 (1M context) --- browse/src/url-validation.ts | 13 ++++++------- browse/test/url-validation.test.ts | 4 ++++ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/browse/src/url-validation.ts b/browse/src/url-validation.ts index a619f1825..02992f4bf 100644 --- a/browse/src/url-validation.ts +++ b/browse/src/url-validation.ts @@ -19,14 +19,15 @@ export const BLOCKED_METADATA_HOSTS = new Set([ ]); /** - * IPv6 prefixes to block (CIDR-style). Any address starting with these - * hex prefixes is rejected. Covers the full ULA range (fc00::/7 = fc00:: and fd00::). + * IPv6 prefixes to block (CIDR-style). ULA addresses cover fc00::/7 and + * link-local addresses cover fe80::/10. */ -const BLOCKED_IPV6_PREFIXES = ['fc', 'fd']; +const BLOCKED_IPV6_PREFIXES = ['fc', 'fd', 'fe8', 'fe9', 'fea', 'feb']; /** * Check if an IPv6 address falls within a blocked prefix range. - * Handles the full ULA range (fc00::/7), not just the exact literal fd00::. + * Handles the full ULA range (fc00::/7) and link-local range (fe80::/10), + * not just exact literals like fd00:: or fe80::1. * Only matches actual IPv6 addresses (must contain ':'), not hostnames * like fd.example.com or fcustomer.com. */ @@ -95,9 +96,7 @@ async function resolvesToBlockedIp(hostname: string): Promise { const v6Check = resolve6(hostname).then( (addresses) => addresses.some(addr => { const normalized = addr.toLowerCase(); - return BLOCKED_METADATA_HOSTS.has(normalized) || isBlockedIpv6(normalized) || - // fe80::/10 is link-local — always block (covers all fe80:: addresses) - normalized.startsWith('fe80:'); + return BLOCKED_METADATA_HOSTS.has(normalized) || isBlockedIpv6(normalized); }), () => false, // ENODATA / ENOTFOUND — no AAAA records, not a risk ); diff --git a/browse/test/url-validation.test.ts b/browse/test/url-validation.test.ts index 55af0af8d..8f4ab6962 100644 --- a/browse/test/url-validation.test.ts +++ b/browse/test/url-validation.test.ts @@ -99,6 +99,10 @@ describe('validateNavigationUrl', () => { await expect(validateNavigationUrl('http://[fc00::]/')).rejects.toThrow(/cloud metadata/i); }); + it('blocks direct IPv6 link-local addresses', async () => { + await expect(validateNavigationUrl('http://[fe80::2]/')).rejects.toThrow(/cloud metadata/i); + }); + it('does not block hostnames starting with fd (e.g. fd.example.com)', async () => { await expect(validateNavigationUrl('https://fd.example.com/')).resolves.toBe('https://fd.example.com/'); });