fix(browse): allow about:blank so a restarted daemon can initialise

`validateNavigationUrl` rejected every non-http(s)/file scheme, including
`about:blank`. But the daemon opens its own first tab on about:blank, so once it
restarted it could never come back:

    $ browse newtab about:blank
    Blocked: scheme "about:" is not allowed. Only http:, https:, and file: URLs
    are permitted.

The visible damage is in make-pdf, whose Chromium smoke test is exactly that
command. `make-pdf setup` reports:

    [2/5] Launching Chromium... FAIL
    Chromium failed to launch: browse newtab exited 1

against a completely healthy Chromium, and generate fails downstream with
`page.pdf: Protocol error (IO.read): Read failed` — talking to a daemon that
never finished starting. Both symptoms point away from the actual cause, which
is why this took a while to find.

It only reproduces once the daemon restarts. A daemon still holding its original
tab keeps working, so the bug hides until something recycles the process, and
then make-pdf is dead until the machine is rebooted or the tab is recreated by
some other means.

Scoped as narrowly as it can be: about:blank EXACTLY, matched on href, not the
`about:` scheme. about:blank has no origin, loads nothing and runs nothing;
about:config and about:net-internals are real surfaces and stay blocked, as does
about:blankfoo — this is not a prefix test.

Compared lower-cased because the URL parser normalises the protocol but not the
opaque part, so `ABOUT:BLANK` parses to href `about:BLANK`. Caught by the test
rather than by reading the spec.

Tests: 4 added — about:blank resolves, case-insensitively; about:config and
about:net-internals still rejected; about:blankfoo still rejected.

Note for reviewers on Windows: browse/test/url-validation.test.ts has 5
pre-existing failures on this platform, all in the file:// suites, which assume
POSIX paths (/tmp, /etc/passwd). Unrelated to this change and unchanged by it —
same 5 before and after.
This commit is contained in:
Jake Wilk 2026-08-12 16:36:28 -04:00
parent d078622b73
commit c33705b4f9
2 changed files with 38 additions and 1 deletions

View File

@ -269,9 +269,24 @@ export async function validateNavigationUrl(url: string): Promise<string> {
return pathToFileURL(fsPath).href + parsed.search + parsed.hash;
}
// about:blank ONLY — the canonical empty page, and the one the daemon opens its own
// first tab on. Blocking it meant `browse newtab about:blank` failed, which is what
// `make-pdf setup` runs as its Chromium smoke test: make-pdf reported "Chromium failed
// to launch" against a perfectly healthy Chromium, and any browse session whose daemon
// restarted could never recreate the blank tab it starts from.
//
// Deliberately not the whole `about:` scheme. about:blank has no origin, loads nothing
// and runs nothing; about:config, about:net-internals and friends are real surfaces.
// Exact href match, not a prefix test, so `about:blankfoo` stays blocked.
// Compared lower-cased: the URL parser normalises the PROTOCOL but not the opaque part,
// so `ABOUT:BLANK` parses to href `about:BLANK` and an exact === would reject it.
if (parsed.protocol === 'about:' && parsed.href.toLowerCase() === 'about:blank') {
return 'about:blank';
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(
`Blocked: scheme "${parsed.protocol}" is not allowed. Only http:, https:, and file: URLs are permitted.`
`Blocked: scheme "${parsed.protocol}" is not allowed. Only http:, https:, file:, and about:blank URLs are permitted.`
);
}

View File

@ -47,6 +47,28 @@ describe('validateNavigationUrl', () => {
await expect(validateNavigationUrl('file://host.example.com/foo.html')).rejects.toThrow(/Unsupported file URL host/i);
});
// The daemon opens its own first tab on about:blank, so blocking it meant a restarted
// daemon could never initialise — and `make-pdf setup`, whose Chromium smoke test is
// `browse newtab about:blank`, reported "Chromium failed to launch" on a healthy browser.
it('allows about:blank — the daemon opens its own first tab there', async () => {
await expect(validateNavigationUrl('about:blank')).resolves.toBe('about:blank');
});
it('allows about:blank regardless of case, since URL parsing normalises it', async () => {
await expect(validateNavigationUrl('ABOUT:BLANK')).resolves.toBe('about:blank');
});
// The allowance is about:blank EXACTLY, not the about: scheme. about:blank has no
// origin and loads nothing; the rest of the scheme is a real surface.
it('still blocks other about: URLs', async () => {
await expect(validateNavigationUrl('about:config')).rejects.toThrow(/scheme.*not allowed/i);
await expect(validateNavigationUrl('about:net-internals')).rejects.toThrow(/scheme.*not allowed/i);
});
it('blocks about:blankfoo — exact match, never a prefix test', async () => {
await expect(validateNavigationUrl('about:blankfoo')).rejects.toThrow(/scheme.*not allowed/i);
});
it('blocks javascript: scheme', async () => {
await expect(validateNavigationUrl('javascript:alert(1)')).rejects.toThrow(/scheme.*not allowed/i);
});