diff --git a/e2e/claims.spec.ts b/e2e/claims.spec.ts index 3bd2b77..d16ab37 100644 --- a/e2e/claims.spec.ts +++ b/e2e/claims.spec.ts @@ -142,6 +142,30 @@ test.describe('promise 2, the name check, re-derived independently', () => { }) } + for (const address of [ + 'https://github.com/path@other.example', + 'https://github.com/?email=user@other.example', + 'https://github.com/#user@other.example', + ]) { + test(`URL authority controls the name verdict and ClientHello: ${address}`, async ({ page }) => { + await bootExplore(page) + // Establish a failing state first so a stale baseline cannot satisfy + // the assertions while the asynchronous refresh is still pending. + await page.locator('#address-input').fill('other.example') + await expect(page.locator('[data-verdict="promise-name"]')).toHaveAttribute('data-result', 'fail') + await page.locator('#address-input').fill(address) + const hostname = new URL(address).hostname + await expect(page.locator('[data-verdict="promise-name"]')).toHaveAttribute('data-result', 'pass') + await expect.poll(async () => { + const hex = await read(page, '#wire .bytes') + const bytes = hex.split(/\s+/).filter((t) => /^[0-9a-f]{2}$/.test(t)).map((t) => parseInt(t, 16)) + const text = String.fromCharCode(...bytes) + return text.includes(hostname) && !text.includes('other.example') + }).toBe(true) + await expectClaim(page, 'sni-readable', { contains: hostname }) + }) + } + test('a failing name check names the actual cause', async ({ page }) => { await bootExplore(page) await page.locator('#address-input').fill('evil.example') diff --git a/src/pki/hostname.test.ts b/src/pki/hostname.test.ts index 59ba2a1..b493b0a 100644 --- a/src/pki/hostname.test.ts +++ b/src/pki/hostname.test.ts @@ -58,6 +58,31 @@ describe('RFC 6125 name matching', () => { }) describe('what the reader actually types', () => { + it.each([ + 'https://github.com/contact@docs.example', + 'https://github.com/?email=user@docs.example', + 'https://github.com/#user@docs.example', + 'https://user:pw@github.com:443/path@other.example?email=a@b.example#c@d.example', + 'https://other.example/path@github.com', + 'https://other.example/?email=user@github.com', + 'https://other.example/#user@github.com', + ])('matches the platform URL hostname for %s', (raw) => { + const expected = new URL(raw).hostname + expect(hostFromInput(raw)).toBe(expected) + expect(matchName(raw, [expected]).matched).toBe(true) + expect(matchName(raw, ['github.com']).matched).toBe(expected === 'github.com') + }) + + it.each([ + 'github.com/path@other.example', + 'github.com/?email=user@other.example', + 'github.com/#user@other.example', + 'user:pw@github.com:443/path@other.example', + ])('bounds userinfo to the authority of a bare-host input: %s', (raw) => { + expect(hostFromInput(raw)).toBe(new URL(`https://${raw}`).hostname) + expect(matchName(raw, ['github.com']).matched).toBe(true) + }) + it('extracts the host from a pasted URL, the way a browser does', () => { for (const raw of [ 'https://github.com/', diff --git a/src/pki/hostname.ts b/src/pki/hostname.ts index 6ce3b95..c31093d 100644 --- a/src/pki/hostname.ts +++ b/src/pki/hostname.ts @@ -36,8 +36,10 @@ function normalise(name: string): string { * extracts the host and matches THAT. Worse, the whole URL went into the SNI * exhibit, so the page showed a first message no browser would ever send. * - * So the input is parsed the way a browser parses it: scheme stripped, userinfo - * dropped, port dropped, path and query dropped. What is left is compared. + * This is a bounded parser for scheme://authority URLs and bare-host inputs + * (optionally with userinfo, a port or a path). Strip the scheme, isolate the + * authority, then drop userinfo and the port. An @ in a path, query or fragment + * cannot change the host. This is not a general WHATWG URL implementation. * Anything that still is not a plain DNS name is refused by `isPlainDnsName` * rather than guessed at. */ @@ -46,11 +48,11 @@ export function hostFromInput(raw: string): string { if (s === '') return '' // Scheme, if any. s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '') - // Anything before an @ is userinfo, not a host. + // Path, query or fragment ends the authority BEFORE interpreting userinfo. + s = s.split(/[/?#]/)[0] ?? '' + // Only an @ inside the authority separates userinfo from the host. const at = s.lastIndexOf('@') if (at !== -1) s = s.slice(at + 1) - // Path, query or fragment ends the authority. - s = s.split(/[/?#]/)[0] ?? '' // A bracketed IPv6 literal is not a DNS name; leave it intact and let // isPlainDnsName refuse it rather than mangling it into something plausible. if (s.startsWith('[')) return normalise(s)