From c9e673c6dca746384338ab6bb0cf63c7e7caa9b2 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Thu, 27 Jun 2024 09:29:20 -0700 Subject: [PATCH] fix(utility): create utility world when web security is disabled (#31458) Reverts previous attempt at #31096 Fixes: https://github.com/microsoft/playwright/issues/31431 Fixes: https://github.com/microsoft/playwright/issues/31442 --- .../src/server/browserContext.ts | 12 ++-- .../src/server/chromium/crBrowser.ts | 6 +- .../src/server/chromium/crPage.ts | 46 ++++++------- .../src/server/firefox/ffBrowser.ts | 6 +- .../src/server/firefox/ffPage.ts | 11 +-- packages/playwright-core/src/server/page.ts | 27 ++++++-- .../src/server/webkit/wkBrowser.ts | 4 +- .../src/server/webkit/wkPage.ts | 8 +-- .../chromium/disable-web-security.spec.ts | 68 +++++++++++++++++++ 9 files changed, 138 insertions(+), 50 deletions(-) create mode 100644 tests/library/chromium/disable-web-security.spec.ts diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index c5a5c9a142ccc..92f3f8a20b826 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -24,6 +24,7 @@ import type { Download } from './download'; import type * as frames from './frames'; import { helper } from './helper'; import * as network from './network'; +import { InitScript } from './page'; import type { PageDelegate } from './page'; import { Page, PageBinding } from './page'; import type { Progress, ProgressController } from './progress'; @@ -84,7 +85,7 @@ export abstract class BrowserContext extends SdkObject { private _customCloseHandler?: () => Promise; readonly _tempDirs: string[] = []; private _settingStorageState = false; - readonly initScripts: string[] = []; + readonly initScripts: InitScript[] = []; private _routesInFlight = new Set(); private _debugger!: Debugger; _closeReason: string | undefined; @@ -266,7 +267,7 @@ export abstract class BrowserContext extends SdkObject { protected abstract doGrantPermissions(origin: string, permissions: string[]): Promise; protected abstract doClearPermissions(): Promise; protected abstract doSetHTTPCredentials(httpCredentials?: types.Credentials): Promise; - protected abstract doAddInitScript(expression: string): Promise; + protected abstract doAddInitScript(initScript: InitScript): Promise; protected abstract doRemoveInitScripts(): Promise; protected abstract doExposeBinding(binding: PageBinding): Promise; protected abstract doRemoveExposedBindings(): Promise; @@ -403,9 +404,10 @@ export abstract class BrowserContext extends SdkObject { this._options.httpCredentials = { username, password: password || '' }; } - async addInitScript(script: string) { - this.initScripts.push(script); - await this.doAddInitScript(script); + async addInitScript(source: string) { + const initScript = new InitScript(source); + this.initScripts.push(initScript); + await this.doAddInitScript(initScript); } async _removeInitScripts(): Promise { diff --git a/packages/playwright-core/src/server/chromium/crBrowser.ts b/packages/playwright-core/src/server/chromium/crBrowser.ts index 254a86c164ac6..777ff2eee82fe 100644 --- a/packages/playwright-core/src/server/chromium/crBrowser.ts +++ b/packages/playwright-core/src/server/chromium/crBrowser.ts @@ -21,7 +21,7 @@ import { Browser } from '../browser'; import { assertBrowserContextIsNotOwned, BrowserContext, verifyGeolocation } from '../browserContext'; import { assert, createGuid } from '../../utils'; import * as network from '../network'; -import type { PageBinding, PageDelegate, Worker } from '../page'; +import type { InitScript, PageBinding, PageDelegate, Worker } from '../page'; import { Page } from '../page'; import { Frame } from '../frames'; import type { Dialog } from '../dialog'; @@ -486,9 +486,9 @@ export class CRBrowserContext extends BrowserContext { await (sw as CRServiceWorker).updateHttpCredentials(); } - async doAddInitScript(source: string) { + async doAddInitScript(initScript: InitScript) { for (const page of this.pages()) - await (page._delegate as CRPage).addInitScript(source); + await (page._delegate as CRPage).addInitScript(initScript); } async doRemoveInitScripts() { diff --git a/packages/playwright-core/src/server/chromium/crPage.ts b/packages/playwright-core/src/server/chromium/crPage.ts index 34d1c53cd4236..53b96ad4032c1 100644 --- a/packages/playwright-core/src/server/chromium/crPage.ts +++ b/packages/playwright-core/src/server/chromium/crPage.ts @@ -26,7 +26,7 @@ import * as dom from '../dom'; import * as frames from '../frames'; import { helper } from '../helper'; import * as network from '../network'; -import type { PageBinding, PageDelegate } from '../page'; +import type { InitScript, PageBinding, PageDelegate } from '../page'; import { Page, Worker } from '../page'; import type { Progress } from '../progress'; import type * as types from '../types'; @@ -256,8 +256,8 @@ export class CRPage implements PageDelegate { return this._go(+1); } - async addInitScript(source: string, world: types.World = 'main'): Promise { - await this._forAllFrameSessions(frame => frame._evaluateOnNewDocument(source, world)); + async addInitScript(initScript: InitScript, world: types.World = 'main'): Promise { + await this._forAllFrameSessions(frame => frame._evaluateOnNewDocument(initScript, world)); } async removeInitScripts() { @@ -511,6 +511,20 @@ class FrameSession { this._addRendererListeners(); } + const localFrames = this._isMainFrame() ? this._page.frames() : [this._page._frameManager.frame(this._targetId)!]; + for (const frame of localFrames) { + // Note: frames might be removed before we send these. + this._client._sendMayFail('Page.createIsolatedWorld', { + frameId: frame._id, + grantUniveralAccess: true, + worldName: UTILITY_WORLD_NAME, + }); + for (const binding of this._crPage._browserContext._pageBindings.values()) + frame.evaluateExpression(binding.source).catch(e => {}); + for (const initScript of this._crPage._browserContext.initScripts) + frame.evaluateExpression(initScript.source).catch(e => {}); + } + const isInitialEmptyPage = this._isMainFrame() && this._page.mainFrame().url() === ':'; if (isInitialEmptyPage) { // Ignore lifecycle events, worlds and bindings for the initial empty page. It is never the final page @@ -520,20 +534,6 @@ class FrameSession { this._eventListeners.push(eventsHelper.addEventListener(this._client, 'Page.lifecycleEvent', event => this._onLifecycleEvent(event))); }); } else { - const localFrames = this._isMainFrame() ? this._page.frames() : [this._page._frameManager.frame(this._targetId)!]; - for (const frame of localFrames) { - // Note: frames might be removed before we send these. - this._client._sendMayFail('Page.createIsolatedWorld', { - frameId: frame._id, - grantUniveralAccess: true, - worldName: UTILITY_WORLD_NAME, - }); - for (const binding of this._crPage._browserContext._pageBindings.values()) - frame.evaluateExpression(binding.source).catch(e => {}); - for (const source of this._crPage._browserContext.initScripts) - frame.evaluateExpression(source).catch(e => {}); - } - this._firstNonInitialNavigationCommittedFulfill(); this._eventListeners.push(eventsHelper.addEventListener(this._client, 'Page.lifecycleEvent', event => this._onLifecycleEvent(event))); } @@ -575,10 +575,10 @@ class FrameSession { promises.push(this._updateFileChooserInterception(true)); for (const binding of this._crPage._page.allBindings()) promises.push(this._initBinding(binding)); - for (const source of this._crPage._browserContext.initScripts) - promises.push(this._evaluateOnNewDocument(source, 'main')); - for (const source of this._crPage._page.initScripts) - promises.push(this._evaluateOnNewDocument(source, 'main')); + for (const initScript of this._crPage._browserContext.initScripts) + promises.push(this._evaluateOnNewDocument(initScript, 'main')); + for (const initScript of this._crPage._page.initScripts) + promises.push(this._evaluateOnNewDocument(initScript, 'main')); if (screencastOptions) promises.push(this._startVideoRecording(screencastOptions)); } @@ -1099,9 +1099,9 @@ class FrameSession { await this._client.send('Page.setInterceptFileChooserDialog', { enabled }).catch(() => {}); // target can be closed. } - async _evaluateOnNewDocument(source: string, world: types.World): Promise { + async _evaluateOnNewDocument(initScript: InitScript, world: types.World): Promise { const worldName = world === 'utility' ? UTILITY_WORLD_NAME : undefined; - const { identifier } = await this._client.send('Page.addScriptToEvaluateOnNewDocument', { source, worldName }); + const { identifier } = await this._client.send('Page.addScriptToEvaluateOnNewDocument', { source: initScript.source, worldName }); this._evaluateOnNewDocumentIdentifiers.push(identifier); } diff --git a/packages/playwright-core/src/server/firefox/ffBrowser.ts b/packages/playwright-core/src/server/firefox/ffBrowser.ts index ae26d0994a2db..7ed2d8cb2ff14 100644 --- a/packages/playwright-core/src/server/firefox/ffBrowser.ts +++ b/packages/playwright-core/src/server/firefox/ffBrowser.ts @@ -21,7 +21,7 @@ import type { BrowserOptions } from '../browser'; import { Browser } from '../browser'; import { assertBrowserContextIsNotOwned, BrowserContext, verifyGeolocation } from '../browserContext'; import * as network from '../network'; -import type { Page, PageBinding, PageDelegate } from '../page'; +import type { InitScript, Page, PageBinding, PageDelegate } from '../page'; import type { ConnectionTransport } from '../transport'; import type * as types from '../types'; import type * as channels from '@protocol/channels'; @@ -352,8 +352,8 @@ export class FFBrowserContext extends BrowserContext { await this._browser.session.send('Browser.setHTTPCredentials', { browserContextId: this._browserContextId, credentials }); } - async doAddInitScript(source: string) { - await this._browser.session.send('Browser.setInitScripts', { browserContextId: this._browserContextId, scripts: this.initScripts.map(script => ({ script })) }); + async doAddInitScript(initScript: InitScript) { + await this._browser.session.send('Browser.setInitScripts', { browserContextId: this._browserContextId, scripts: this.initScripts.map(script => ({ script: script.source })) }); } async doRemoveInitScripts() { diff --git a/packages/playwright-core/src/server/firefox/ffPage.ts b/packages/playwright-core/src/server/firefox/ffPage.ts index 77fcd480bb75c..aac22d5e50ed8 100644 --- a/packages/playwright-core/src/server/firefox/ffPage.ts +++ b/packages/playwright-core/src/server/firefox/ffPage.ts @@ -21,6 +21,7 @@ import type * as frames from '../frames'; import type { RegisteredListener } from '../../utils/eventsHelper'; import { eventsHelper } from '../../utils/eventsHelper'; import type { PageBinding, PageDelegate } from '../page'; +import { InitScript } from '../page'; import { Page, Worker } from '../page'; import type * as types from '../types'; import { getAccessibilityTree } from './ffAccessibility'; @@ -56,7 +57,7 @@ export class FFPage implements PageDelegate { private _eventListeners: RegisteredListener[]; private _workers = new Map(); private _screencastId: string | undefined; - private _initScripts: { script: string, worldName?: string }[] = []; + private _initScripts: { initScript: InitScript, worldName?: string }[] = []; constructor(session: FFSession, browserContext: FFBrowserContext, opener: FFPage | null) { this._session = session; @@ -113,7 +114,7 @@ export class FFPage implements PageDelegate { }); // Ideally, we somehow ensure that utility world is created before Page.ready arrives, but currently it is racy. // Therefore, we can end up with an initialized page without utility world, although very unlikely. - this.addInitScript('', UTILITY_WORLD_NAME).catch(e => this._markAsError(e)); + this.addInitScript(new InitScript(''), UTILITY_WORLD_NAME).catch(e => this._markAsError(e)); } potentiallyUninitializedPage(): Page { @@ -406,9 +407,9 @@ export class FFPage implements PageDelegate { return success; } - async addInitScript(script: string, worldName?: string): Promise { - this._initScripts.push({ script, worldName }); - await this._session.send('Page.setInitScripts', { scripts: this._initScripts }); + async addInitScript(initScript: InitScript, worldName?: string): Promise { + this._initScripts.push({ initScript, worldName }); + await this._session.send('Page.setInitScripts', { scripts: this._initScripts.map(s => ({ script: s.initScript.source, worldName: s.worldName })) }); } async removeInitScripts() { diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 28ff203a3c1ce..da9063821c87c 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -31,7 +31,7 @@ import * as accessibility from './accessibility'; import { FileChooser } from './fileChooser'; import type { Progress } from './progress'; import { ProgressController } from './progress'; -import { LongStandingScope, assert, isError } from '../utils'; +import { LongStandingScope, assert, createGuid, isError } from '../utils'; import { ManualPromise } from '../utils/manualPromise'; import { debugLogger } from '../utils/debugLogger'; import type { ImageComparatorOptions } from '../utils/comparators'; @@ -56,7 +56,7 @@ export interface PageDelegate { goForward(): Promise; exposeBinding(binding: PageBinding): Promise; removeExposedBindings(): Promise; - addInitScript(source: string): Promise; + addInitScript(initScript: InitScript): Promise; removeInitScripts(): Promise; closePage(runBeforeUnload: boolean): Promise; potentiallyUninitializedPage(): Page; @@ -154,7 +154,7 @@ export class Page extends SdkObject { private _emulatedMedia: Partial = {}; private _interceptFileChooser = false; private readonly _pageBindings = new Map(); - readonly initScripts: string[] = []; + readonly initScripts: InitScript[] = []; readonly _screenshotter: Screenshotter; readonly _frameManager: frames.FrameManager; readonly accessibility: accessibility.Accessibility; @@ -527,8 +527,9 @@ export class Page extends SdkObject { } async addInitScript(source: string) { - this.initScripts.push(source); - await this._delegate.addInitScript(source); + const initScript = new InitScript(source); + this.initScripts.push(initScript); + await this._delegate.addInitScript(initScript); } async _removeInitScripts() { @@ -905,6 +906,22 @@ function addPageBinding(bindingName: string, needsHandle: boolean, utilityScript (globalThis as any)[bindingName].__installed = true; } +export class InitScript { + readonly source: string; + + constructor(source: string) { + const guid = createGuid(); + this.source = `(() => { + globalThis.__pwInitScripts = globalThis.__pwInitScripts || {}; + const hasInitScript = globalThis.__pwInitScripts[${JSON.stringify(guid)}]; + if (hasInitScript) + return; + globalThis.__pwInitScripts[${JSON.stringify(guid)}] = true; + ${source} + })();`; + } +} + class FrameThrottler { private _acks: (() => void)[] = []; private _defaultInterval: number; diff --git a/packages/playwright-core/src/server/webkit/wkBrowser.ts b/packages/playwright-core/src/server/webkit/wkBrowser.ts index a8541e73cf869..9eedec410bb1d 100644 --- a/packages/playwright-core/src/server/webkit/wkBrowser.ts +++ b/packages/playwright-core/src/server/webkit/wkBrowser.ts @@ -22,7 +22,7 @@ import type { RegisteredListener } from '../../utils/eventsHelper'; import { assert } from '../../utils'; import { eventsHelper } from '../../utils/eventsHelper'; import * as network from '../network'; -import type { Page, PageBinding, PageDelegate } from '../page'; +import type { InitScript, Page, PageBinding, PageDelegate } from '../page'; import type { ConnectionTransport } from '../transport'; import type * as types from '../types'; import type * as channels from '@protocol/channels'; @@ -315,7 +315,7 @@ export class WKBrowserContext extends BrowserContext { await (page._delegate as WKPage).updateHttpCredentials(); } - async doAddInitScript(source: string) { + async doAddInitScript(initScript: InitScript) { for (const page of this.pages()) await (page._delegate as WKPage)._updateBootstrapScript(); } diff --git a/packages/playwright-core/src/server/webkit/wkPage.ts b/packages/playwright-core/src/server/webkit/wkPage.ts index 9507f187e51c3..adfd06c05ee78 100644 --- a/packages/playwright-core/src/server/webkit/wkPage.ts +++ b/packages/playwright-core/src/server/webkit/wkPage.ts @@ -30,7 +30,7 @@ import { eventsHelper } from '../../utils/eventsHelper'; import { helper } from '../helper'; import type { JSHandle } from '../javascript'; import * as network from '../network'; -import type { PageBinding, PageDelegate } from '../page'; +import type { InitScript, PageBinding, PageDelegate } from '../page'; import { Page } from '../page'; import type { Progress } from '../progress'; import type * as types from '../types'; @@ -777,7 +777,7 @@ export class WKPage implements PageDelegate { await this._updateBootstrapScript(); } - async addInitScript(script: string): Promise { + async addInitScript(initScript: InitScript): Promise { await this._updateBootstrapScript(); } @@ -797,8 +797,8 @@ export class WKPage implements PageDelegate { for (const binding of this._page.allBindings()) scripts.push(binding.source); - scripts.push(...this._browserContext.initScripts); - scripts.push(...this._page.initScripts); + scripts.push(...this._browserContext.initScripts.map(s => s.source)); + scripts.push(...this._page.initScripts.map(s => s.source)); return scripts.join(';\n'); } diff --git a/tests/library/chromium/disable-web-security.spec.ts b/tests/library/chromium/disable-web-security.spec.ts new file mode 100644 index 0000000000000..fab5599a762be --- /dev/null +++ b/tests/library/chromium/disable-web-security.spec.ts @@ -0,0 +1,68 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * Modifications copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { contextTest as it, expect } from '../../config/browserTest'; + +it.use({ + launchOptions: async ({ launchOptions }, use) => { + await use({ ...launchOptions, args: ['--disable-web-security'] }); + } +}); + +it('test utility world in popup w/ --disable-web-security', async ({ page, server }) => { + server.setRoute('/main.html', (req, res) => { + res.writeHead(200, { + 'Content-Type': 'text/html' + }); + res.end(`Click me`); + }); + server.setRoute('/target.html', (req, res) => { + res.writeHead(200, { + 'Content-Type': 'text/html' + }); + res.end(``); + }); + + await page.goto(server.PREFIX + '/main.html'); + const page1Promise = page.context().waitForEvent('page'); + await page.getByRole('link', { name: 'Click me' }).click(); + const page1 = await page1Promise; + await expect(page1).toHaveURL(/target/); +}); + +it('test init script w/ --disable-web-security', async ({ page, server }) => { + server.setRoute('/main.html', (req, res) => { + res.writeHead(200, { + 'Content-Type': 'text/html' + }); + res.end(`Click me`); + }); + server.setRoute('/target.html', (req, res) => { + res.writeHead(200, { + 'Content-Type': 'text/html' + }); + res.end(``); + }); + + await page.context().addInitScript('window.injected = 123'); + await page.goto(server.PREFIX + '/main.html'); + const page1Promise = page.context().waitForEvent('page'); + await page.getByRole('link', { name: 'Click me' }).click(); + const page1 = await page1Promise; + const value = await page1.evaluate('window.injected'); + expect(value).toBe(123); +});