diff --git a/src/__tests__/network.test.ts b/src/__tests__/network.test.ts index adfbd5e..6dceac4 100644 --- a/src/__tests__/network.test.ts +++ b/src/__tests__/network.test.ts @@ -34,6 +34,19 @@ describe('validateUrl', () => { expect(validateUrl('http://127.0.0.1:8000', { allowInsecureLocalhost: true }).hostname).toBe( '127.0.0.1', ); + // Full 127.0.0.0/8 range — not just .1 + expect(validateUrl('http://127.0.0.2:8000', { allowInsecureLocalhost: true }).hostname).toBe( + '127.0.0.2', + ); + // IPv6 loopback — WHATWG URL parser always returns the bracketed form + expect(validateUrl('http://[::1]:8000', { allowInsecureLocalhost: true }).hostname).toBe( + '[::1]', + ); + }); + + it('rejects http on loopback addresses unless opted in', () => { + expect(() => validateUrl('http://127.0.0.2:8000')).toThrow(VeroError); + expect(() => validateUrl('http://[::1]:8000')).toThrow(VeroError); }); it('does not permit http on a remote host even when localhost is opted in', () => { diff --git a/src/network/index.ts b/src/network/index.ts index 1a2028a..a737309 100644 --- a/src/network/index.ts +++ b/src/network/index.ts @@ -38,7 +38,20 @@ export const MAINNET: NetworkConfig = { network: 'mainnet', }; -const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']); +/** + * Returns true for hostnames that are loopback addresses: + * - "localhost" + * - IPv4 127.0.0.0/8 (e.g. 127.0.0.1, 127.0.0.2, …) + * - IPv6 [::1] (as returned by the WHATWG URL parser — always bracketed) + * + * Note: the WHATWG URL parser always brackets IPv6 addresses, so bare "::1" + * can never appear in `parsed.hostname` and is intentionally excluded here. + */ +function isLoopback(hostname: string): boolean { + if (hostname === 'localhost' || hostname === '[::1]') return true; + // Match 127.x.x.x (the full 127.0.0.0/8 loopback range) + return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname); +} export interface ValidateUrlOptions { /** @@ -69,7 +82,7 @@ export function validateUrl(url: string, opts: ValidateUrlOptions = {}): URL { if (parsed.protocol === 'https:') return parsed; if (parsed.protocol === 'http:') { - const isLocal = LOCAL_HOSTS.has(parsed.hostname); + const isLocal = isLoopback(parsed.hostname); if (isLocal && opts.allowInsecureLocalhost) return parsed; throw new VeroError(