diff --git a/lib/src/components/wall/browser-url.test.ts b/lib/src/components/wall/browser-url.test.ts index f2f73ba5..7fb582da 100644 --- a/lib/src/components/wall/browser-url.test.ts +++ b/lib/src/components/wall/browser-url.test.ts @@ -52,6 +52,19 @@ describe('normalizeNavUrl', () => { expect(normalizeNavUrl('app.localhost:3000')).toBe('http://app.localhost:3000'); }); + it('adds http:// for a bracketed IPv6 loopback literal, with or without a port', () => { + // `[::1]` is loopback whether or not a port pins it to http: splitting the + // authority on the first `:` would read the hostname as `[` and fall through + // to https, which just SSL-errors there. + expect(normalizeNavUrl('[::1]')).toBe('http://[::1]'); + expect(normalizeNavUrl('[::1]/app?q=1')).toBe('http://[::1]/app?q=1'); + expect(normalizeNavUrl('[::1]:5173')).toBe('http://[::1]:5173'); + }); + + it('adds https:// for a bare non-loopback IPv6 literal', () => { + expect(normalizeNavUrl('[2001:db8::1]')).toBe('https://[2001:db8::1]'); + }); + it('adds http:// for any host with an explicit port (matches the dor CLI)', () => { // The port is the dev/infra-server signal — LAN and Tailnet hosts speak http. expect(normalizeNavUrl('example.com:8080')).toBe('http://example.com:8080'); diff --git a/lib/src/components/wall/browser-url.ts b/lib/src/components/wall/browser-url.ts index 3fa8a7c1..715fb0a3 100644 --- a/lib/src/components/wall/browser-url.ts +++ b/lib/src/components/wall/browser-url.ts @@ -58,11 +58,21 @@ export function normalizeNavUrl(raw: string): string { if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed)) return trimmed; if (/^(about|data|blob|mailto|tel|javascript|view-source|chrome):/i.test(trimmed)) return trimmed; const authority = trimmed.split(/[/?#]/, 1)[0]; - const hostname = authority.split(':', 1)[0]; + const hostname = authorityHostname(authority); const scheme = /:\d+$/.test(authority) || isLoopbackHostname(hostname) ? 'http' : 'https'; return `${scheme}://${trimmed}`; } +/** The host part of a schemeless authority, minus any `:port`. An IPv6 literal + * is bracketed and full of colons, so splitting on the first `:` would yield + * `[` — take everything through the closing bracket instead, which keeps the + * form `isLoopbackHostname` recognizes. */ +function authorityHostname(authority: string): string { + if (!authority.startsWith('[')) return authority.split(':', 1)[0]; + const close = authority.indexOf(']'); + return close === -1 ? authority : authority.slice(0, close + 1); +} + /** True for hostnames that resolve to the local machine. `*.localhost` is * included because browsers route it to loopback per the RFC. */ function isLoopbackHostname(hostname: string): boolean {