From d2b480588a2c3e53b251f217f991657b860ec4f3 Mon Sep 17 00:00:00 2001 From: krishpranav Date: Sat, 25 Jul 2026 14:56:54 +0530 Subject: [PATCH 1/3] fix(chromium): refuse WebUI navigations that crash the browser Chromium does not allow a handful of WebUI hosts in off-the-record profiles and redirects them to a normal window in the original profile. That redirect segfaults the browser process when the profile was created over CDP, which is the case for every non-persistent context. Refuse the navigation instead. Fixes: https://github.com/microsoft/playwright/issues/41935 --- .../src/server/chromium/crPage.ts | 26 +++++++++++++ tests/library/chromium/chromium.spec.ts | 38 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/packages/playwright-core/src/server/chromium/crPage.ts b/packages/playwright-core/src/server/chromium/crPage.ts index 12d91c0fc1bd4..e7ef5f2a72e52 100644 --- a/packages/playwright-core/src/server/chromium/crPage.ts +++ b/packages/playwright-core/src/server/chromium/crPage.ts @@ -49,6 +49,19 @@ import type * as channels from '../channels'; export type WindowBounds = { top?: number, left?: number, width?: number, height?: number }; +// Chromium does not allow these WebUI hosts in off-the-record profiles, and instead redirects them +// to a normal window in the original profile. That redirect crashes the browser process when the +// profile was created over CDP, as it is for every non-persistent context. Refuse the navigation +// rather than lose the whole browser. See https://github.com/microsoft/playwright/issues/41935. +const kWebUIHostsUnavailableOffTheRecord = new Set([ + 'apps', + 'extensions', + 'help', + 'history', + 'password-manager', + 'settings', +]); + export class CRPage implements PageDelegate { readonly utilityWorldName: string; readonly _mainFrameSession: FrameSession; @@ -152,9 +165,22 @@ export class CRPage implements PageDelegate { } async navigateFrame(frame: frames.Frame, url: string, referrer: string | undefined): Promise { + this._assertNavigationDoesNotCrashBrowser(url); return this._sessionForFrame(frame)._navigate(frame, url, referrer); } + private _assertNavigationDoesNotCrashBrowser(url: string) { + if (this._browserContext.isPersistentContext()) + return; + if (!URL.canParse(url)) + return; + // "chrome:" is not a special scheme, so the URL parser leaves the host case alone + // while Chromium resolves it case-insensitively. + const { protocol, hostname } = new URL(url); + if (protocol === 'chrome:' && kWebUIHostsUnavailableOffTheRecord.has(hostname.toLowerCase())) + throw new Error(`Cannot navigate to "${url}": Chromium does not allow this page in an isolated browser context, and opening it crashes the browser. Use browserType.launchPersistentContext() instead.`); + } + async updateExtraHTTPHeaders(): Promise { const headers = network.mergeHeaders([ this._browserContext._options.extraHTTPHeaders, diff --git a/tests/library/chromium/chromium.spec.ts b/tests/library/chromium/chromium.spec.ts index 16732fc5fd531..3c15b2fa929ac 100644 --- a/tests/library/chromium/chromium.spec.ts +++ b/tests/library/chromium/chromium.spec.ts @@ -747,6 +747,44 @@ test('should capture console.log from ServiceWorker start', async ({ context, pa expect(consoleMessage.type()).toBe('log'); }); +test.describe('chrome:// navigation', () => { + // Chromium disallows these WebUI hosts in off-the-record profiles. + const unavailableHosts = ['apps', 'extensions', 'help', 'history', 'password-manager', 'settings']; + + test('should refuse WebUI pages that crash the browser in an isolated context', async ({ browser, page }) => { + test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/41935' }); + + for (const url of [...unavailableHosts.map(host => `chrome://${host}`), 'chrome://extensions/', 'chrome://settings/help', 'chrome://SETTINGS']) { + const error = await page.goto(url).catch(e => e); + expect(error.message).toContain(`Cannot navigate to "${url}"`); + } + // The refusal only matters because it keeps the browser process alive. + expect(browser.isConnected()).toBe(true); + expect(await page.evaluate(() => 1 + 1)).toBe(2); + }); + + test('should navigate to WebUI pages that work in an isolated context', async ({ page, headless }) => { + test.skip(headless, 'WebUI pages are not available in headless'); + + const response = await page.goto('chrome://version'); + expect(response.status()).toBe(200); + }); + + test('should navigate to any WebUI page in a persistent context', async ({ browserType, createUserDataDir, headless }) => { + test.skip(headless, 'WebUI pages are not available in headless'); + + const context = await browserType.launchPersistentContext(await createUserDataDir()); + try { + const page = await context.newPage(); + // Committing the navigation is all this asserts - waiting for the WebUI to load is slow and beside the point. + const response = await page.goto('chrome://extensions', { waitUntil: 'commit' }); + expect(response.status()).toBe(200); + } finally { + await context.close(); + } + }); +}); + test('should fire dialogclosed event when dialog is closed out of band', async ({ page }) => { // Establish the CDP session up front: creating one while a dialog is blocking the page hangs. const client = await page.context().newCDPSession(page); From 9cf2951c15b7065a746ce24cfd57560b1126aeaa Mon Sep 17 00:00:00 2001 From: krishpranav Date: Tue, 28 Jul 2026 18:17:11 +0530 Subject: [PATCH 2/3] fix(chromium): also refuse edge:// and other webui url spellings --- .../src/server/chromium/crPage.ts | 26 ++++++++++--------- tests/library/chromium/chromium.spec.ts | 17 +++++++++--- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/packages/playwright-core/src/server/chromium/crPage.ts b/packages/playwright-core/src/server/chromium/crPage.ts index e7ef5f2a72e52..0ed2a287d2265 100644 --- a/packages/playwright-core/src/server/chromium/crPage.ts +++ b/packages/playwright-core/src/server/chromium/crPage.ts @@ -49,11 +49,10 @@ import type * as channels from '../channels'; export type WindowBounds = { top?: number, left?: number, width?: number, height?: number }; -// Chromium does not allow these WebUI hosts in off-the-record profiles, and instead redirects them -// to a normal window in the original profile. That redirect crashes the browser process when the -// profile was created over CDP, as it is for every non-persistent context. Refuse the navigation -// rather than lose the whole browser. See https://github.com/microsoft/playwright/issues/41935. -const kWebUIHostsUnavailableOffTheRecord = new Set([ +// Chromium disallows these WebUI hosts in off-the-record profiles and redirects them to the original +// profile, which crashes browsers launched over CDP. +// See https://github.com/microsoft/playwright/issues/41935. +const kCrashingWebUIHosts = new Set([ 'apps', 'extensions', 'help', @@ -62,6 +61,14 @@ const kWebUIHostsUnavailableOffTheRecord = new Set([ 'settings', ]); +// Chromium canonicalizes WebUI urls as standard ones, so "VIEW-SOURCE:Chrome:Settings" ends up +// being "chrome://settings/". +function webUIHost(url: string): string { + const match = /^(?:view-source:)?(?:chrome|edge):\/*([^/?#]+)/i.exec(url); + const authority = match ? `http://${match[1]}` : ''; + return URL.canParse(authority) ? new URL(authority).hostname : ''; +} + export class CRPage implements PageDelegate { readonly utilityWorldName: string; readonly _mainFrameSession: FrameSession; @@ -172,13 +179,8 @@ export class CRPage implements PageDelegate { private _assertNavigationDoesNotCrashBrowser(url: string) { if (this._browserContext.isPersistentContext()) return; - if (!URL.canParse(url)) - return; - // "chrome:" is not a special scheme, so the URL parser leaves the host case alone - // while Chromium resolves it case-insensitively. - const { protocol, hostname } = new URL(url); - if (protocol === 'chrome:' && kWebUIHostsUnavailableOffTheRecord.has(hostname.toLowerCase())) - throw new Error(`Cannot navigate to "${url}": Chromium does not allow this page in an isolated browser context, and opening it crashes the browser. Use browserType.launchPersistentContext() instead.`); + if (kCrashingWebUIHosts.has(webUIHost(url))) + throw new Error(`Cannot navigate to "${url}": this page is not available in an isolated browser context, and opening it crashes the browser. Use browserType.launchPersistentContext() instead.`); } async updateExtraHTTPHeaders(): Promise { diff --git a/tests/library/chromium/chromium.spec.ts b/tests/library/chromium/chromium.spec.ts index 3c15b2fa929ac..c21ce7fa1f309 100644 --- a/tests/library/chromium/chromium.spec.ts +++ b/tests/library/chromium/chromium.spec.ts @@ -747,14 +747,23 @@ test('should capture console.log from ServiceWorker start', async ({ context, pa expect(consoleMessage.type()).toBe('log'); }); -test.describe('chrome:// navigation', () => { - // Chromium disallows these WebUI hosts in off-the-record profiles. - const unavailableHosts = ['apps', 'extensions', 'help', 'history', 'password-manager', 'settings']; +test.describe('WebUI navigation', () => { + const crashingHosts = ['apps', 'extensions', 'help', 'history', 'password-manager', 'settings']; + const crashingUrls = [ + ...crashingHosts.map(host => `chrome://${host}`), + 'chrome://extensions/', + 'chrome://settings/help', + 'chrome://SETTINGS', + 'chrome:settings', + 'chrome:///settings', + 'view-source:chrome://settings', + 'edge://settings', + ]; test('should refuse WebUI pages that crash the browser in an isolated context', async ({ browser, page }) => { test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/41935' }); - for (const url of [...unavailableHosts.map(host => `chrome://${host}`), 'chrome://extensions/', 'chrome://settings/help', 'chrome://SETTINGS']) { + for (const url of crashingUrls) { const error = await page.goto(url).catch(e => e); expect(error.message).toContain(`Cannot navigate to "${url}"`); } From 937112894dc348f9593f91d17679d615e7fa72f0 Mon Sep 17 00:00:00 2001 From: krishpranav Date: Tue, 28 Jul 2026 18:48:11 +0530 Subject: [PATCH 3/3] fix(chromium): use the per-browser list of crashing webui pages --- .../src/server/chromium/crPage.ts | 19 ++++------ tests/library/chromium/chromium.spec.ts | 38 ++++++++++--------- 2 files changed, 29 insertions(+), 28 deletions(-) diff --git a/packages/playwright-core/src/server/chromium/crPage.ts b/packages/playwright-core/src/server/chromium/crPage.ts index 0ed2a287d2265..52aab753da552 100644 --- a/packages/playwright-core/src/server/chromium/crPage.ts +++ b/packages/playwright-core/src/server/chromium/crPage.ts @@ -49,17 +49,13 @@ import type * as channels from '../channels'; export type WindowBounds = { top?: number, left?: number, width?: number, height?: number }; -// Chromium disallows these WebUI hosts in off-the-record profiles and redirects them to the original -// profile, which crashes browsers launched over CDP. +// Browsers disallow these WebUI hosts in off-the-record profiles and redirect them to the original +// profile, which crashes when the profile was created over CDP. Edge allows most of them in InPrivate. // See https://github.com/microsoft/playwright/issues/41935. -const kCrashingWebUIHosts = new Set([ - 'apps', - 'extensions', - 'help', - 'history', - 'password-manager', - 'settings', -]); +const kCrashingWebUIHosts = { + chromium: new Set(['apps', 'extensions', 'help', 'history', 'password-manager', 'settings']), + edge: new Set(['history']), +}; // Chromium canonicalizes WebUI urls as standard ones, so "VIEW-SOURCE:Chrome:Settings" ends up // being "chrome://settings/". @@ -179,7 +175,8 @@ export class CRPage implements PageDelegate { private _assertNavigationDoesNotCrashBrowser(url: string) { if (this._browserContext.isPersistentContext()) return; - if (kCrashingWebUIHosts.has(webUIHost(url))) + const isEdge = this._browserContext._browser.userAgent().includes('Edg/'); + if ((isEdge ? kCrashingWebUIHosts.edge : kCrashingWebUIHosts.chromium).has(webUIHost(url))) throw new Error(`Cannot navigate to "${url}": this page is not available in an isolated browser context, and opening it crashes the browser. Use browserType.launchPersistentContext() instead.`); } diff --git a/tests/library/chromium/chromium.spec.ts b/tests/library/chromium/chromium.spec.ts index c21ce7fa1f309..3eccc7fc2991b 100644 --- a/tests/library/chromium/chromium.spec.ts +++ b/tests/library/chromium/chromium.spec.ts @@ -18,6 +18,8 @@ import { contextTest as test, expect } from '../../config/browserTest'; import { playwrightTest } from '../../config/browserTest'; +import type { Page } from 'playwright-core'; + test('should create a worker from a service worker', async ({ page, server }) => { const [worker] = await Promise.all([ page.context().waitForEvent('serviceworker'), @@ -748,30 +750,32 @@ test('should capture console.log from ServiceWorker start', async ({ context, pa }); test.describe('WebUI navigation', () => { - const crashingHosts = ['apps', 'extensions', 'help', 'history', 'password-manager', 'settings']; - const crashingUrls = [ - ...crashingHosts.map(host => `chrome://${host}`), - 'chrome://extensions/', - 'chrome://settings/help', - 'chrome://SETTINGS', - 'chrome:settings', - 'chrome:///settings', - 'view-source:chrome://settings', - 'edge://settings', - ]; - - test('should refuse WebUI pages that crash the browser in an isolated context', async ({ browser, page }) => { + const isEdge = (channel: string | undefined) => !!channel?.startsWith('msedge'); + const gotoError = (page: Page, url: string) => page.goto(url).then(() => '', e => e.message); + + test('should refuse WebUI pages that crash the browser in an isolated context', async ({ browser, page, channel }) => { test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/41935' }); + test.skip(isEdge(channel), 'Edge only disallows chrome://history'); - for (const url of crashingUrls) { - const error = await page.goto(url).catch(e => e); - expect(error.message).toContain(`Cannot navigate to "${url}"`); - } + const hosts = ['apps', 'extensions', 'help', 'history', 'password-manager', 'settings']; + for (const url of [...hosts.map(host => `chrome://${host}`), 'chrome://extensions/', 'chrome://settings/help', 'chrome://SETTINGS', 'chrome:settings', 'chrome:///settings', 'view-source:chrome://settings']) + expect(await gotoError(page, url)).toContain(`Cannot navigate to "${url}"`); // The refusal only matters because it keeps the browser process alive. expect(browser.isConnected()).toBe(true); expect(await page.evaluate(() => 1 + 1)).toBe(2); }); + test('should refuse WebUI pages that crash Edge in an isolated context', async ({ browser, page, channel }) => { + test.skip(!isEdge(channel), 'Edge has its own list of pages disallowed in InPrivate'); + + for (const url of ['edge://history', 'chrome://history', 'view-source:edge://history']) + expect(await gotoError(page, url)).toContain(`Cannot navigate to "${url}"`); + // Edge does allow these in InPrivate, so they must not be refused. + for (const url of ['edge://settings', 'edge://extensions']) + expect(await gotoError(page, url)).not.toContain('Cannot navigate to'); + expect(browser.isConnected()).toBe(true); + }); + test('should navigate to WebUI pages that work in an isolated context', async ({ page, headless }) => { test.skip(headless, 'WebUI pages are not available in headless');