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
24 changes: 24 additions & 0 deletions e2e/claims.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
25 changes: 25 additions & 0 deletions src/pki/hostname.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/',
Expand Down
12 changes: 7 additions & 5 deletions src/pki/hostname.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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)
Expand Down
Loading