From aa9324e0fd0faf8c96dea60eb360e61b1cc63ea1 Mon Sep 17 00:00:00 2001 From: dep Date: Sun, 5 Jul 2026 07:33:45 -0400 Subject: [PATCH 1/4] feat: emit listener add/remove events from ExtensionRouter Adds a small EventEmitter to ExtensionRouter which emits 'listener-added' and 'listener-removed' with (eventName, extensionId). This allows API implementations to lazily create backing resources only while extension listeners exist. Removals via extension unload and host destruction are also emitted. Co-Authored-By: Claude Fable 5 --- .../src/browser/router.ts | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/electron-chrome-extensions/src/browser/router.ts b/packages/electron-chrome-extensions/src/browser/router.ts index 49e92929..ebda7921 100644 --- a/packages/electron-chrome-extensions/src/browser/router.ts +++ b/packages/electron-chrome-extensions/src/browser/router.ts @@ -1,4 +1,5 @@ import { app, ipcMain, Session } from 'electron' +import { EventEmitter } from 'node:events' import debug from 'debug' import { resolvePartition } from './partition' @@ -219,6 +220,17 @@ export class ExtensionRouter { private handlers: HandlerMap = new Map() private listeners: Map = new Map() + /** + * Emits notifications when extension event listeners change. + * + * - 'listener-added' (eventName: string, extensionId: string) + * - 'listener-removed' (eventName: string, extensionId: string) + * + * Used by APIs which need to lazily set up backing implementations only + * while extension listeners exist. + */ + readonly events = new EventEmitter() + /** * Collection of all extension hosts in the session. * @@ -272,7 +284,7 @@ export class ExtensionRouter { private filterListeners(predicate: (listener: EventListener) => boolean) { for (const [eventName, listeners] of this.listeners) { const filteredListeners = listeners.filter(predicate) - const delta = listeners.length - filteredListeners.length + const removedListeners = listeners.filter((listener) => !predicate(listener)) if (filteredListeners.length > 0) { this.listeners.set(eventName, filteredListeners) @@ -280,8 +292,12 @@ export class ExtensionRouter { this.listeners.delete(eventName) } - if (delta > 0) { - d(`removed ${delta} listener(s) for '${eventName}'`) + if (removedListeners.length > 0) { + d(`removed ${removedListeners.length} listener(s) for '${eventName}'`) + + for (const listener of removedListeners) { + this.events.emit('listener-removed', eventName, listener.extensionId) + } } } } @@ -319,6 +335,7 @@ export class ExtensionRouter { if (listener.type === 'frame' && listener.host) { this.observeListenerHost(listener.host) } + this.events.emit('listener-added', eventName, extensionId) } } @@ -336,6 +353,7 @@ export class ExtensionRouter { if (index >= 0) { d(`removing '${eventName}' event listener for ${extensionId}`) eventListeners.splice(index, 1) + this.events.emit('listener-removed', eventName, extensionId) } if (eventListeners.length === 0) { From c79bac5dbb1f01d6018378c600cd341518af7054 Mon Sep 17 00:00:00 2001 From: dep Date: Sun, 5 Jul 2026 07:34:02 -0400 Subject: [PATCH 2/4] feat: observational chrome.webRequest support Implements chrome.webRequest events backed by Electron's session.webRequest module. Main process (WebRequestAPI): - Observational only: Electron's blocking-capable callbacks are resolved immediately with {} before fanning out to extensions. - Lazily attaches a session.webRequest listener per event only while at least one extension listens for it, and detaches when the last extension listener is removed. This matters because registering any session.webRequest listener disables Chromium's built-in extension webRequest and declarativeNetRequest handling for new URLLoaderFactories in the session. - Maps Electron listener details to Chrome webRequest details, including resource types, headers, request bodies, and tab IDs. - Events are sent per-extension and gated on the webRequest permission and manifest host permissions to avoid leaking request URLs. Renderer preload: - Full chrome.webRequest surface with all nine events, handlerBehaviorChanged, and MAX_HANDLER_BEHAVIOR_CHANGED_CALLS_PER_10_MINUTES. - Listeners are filtered by RequestFilter (urls/types/tabId) and headers/request bodies are removed unless requested via extraInfoSpec. - onAuthRequired can be registered but never emits since Electron provides no session.webRequest equivalent. Co-Authored-By: Claude Fable 5 --- .../src/browser/api/web-request.ts | 330 ++++++++++++++++++ .../src/browser/index.ts | 3 + .../src/renderer/index.ts | 145 +++++++- 3 files changed, 477 insertions(+), 1 deletion(-) create mode 100644 packages/electron-chrome-extensions/src/browser/api/web-request.ts diff --git a/packages/electron-chrome-extensions/src/browser/api/web-request.ts b/packages/electron-chrome-extensions/src/browser/api/web-request.ts new file mode 100644 index 00000000..ec8885d6 --- /dev/null +++ b/packages/electron-chrome-extensions/src/browser/api/web-request.ts @@ -0,0 +1,330 @@ +import debug from 'debug' +import { ExtensionContext } from '../context' +import { getExtensionManifest, matchesPattern } from './common' + +const d = debug('electron-chrome-extensions:webRequest') + +/** + * `chrome.webRequest` events backed by Electron's `session.webRequest` module. + * + * This implementation is observational only. Electron's blocking callbacks + * are always resolved immediately without modification—extensions can observe + * requests, but can't block or modify them. + */ +type WebRequestEventName = + | 'onBeforeRequest' + | 'onBeforeSendHeaders' + | 'onSendHeaders' + | 'onHeadersReceived' + | 'onResponseStarted' + | 'onBeforeRedirect' + | 'onCompleted' + | 'onErrorOccurred' + +const WEB_REQUEST_EVENTS: WebRequestEventName[] = [ + 'onBeforeRequest', + 'onBeforeSendHeaders', + 'onSendHeaders', + 'onHeadersReceived', + 'onResponseStarted', + 'onBeforeRedirect', + 'onCompleted', + 'onErrorOccurred', +] + +/** Events which Electron invokes with a callback capable of blocking the request. */ +const BLOCKING_EVENTS: Set = new Set([ + 'onBeforeRequest', + 'onBeforeSendHeaders', + 'onHeadersReceived', +] as WebRequestEventName[]) + +/** + * Union of the properties found in Electron's webRequest listener details. + * Each event provides a subset of these. + */ +interface ElectronRequestDetails { + id: number + url: string + method: string + webContentsId?: number + webContents?: Electron.WebContents + frame?: Electron.WebFrameMain | null + resourceType: string + referrer: string + timestamp: number + uploadData?: Electron.UploadData[] + requestHeaders?: Record + responseHeaders?: Record + statusLine?: string + statusCode?: number + redirectURL?: string + ip?: string + fromCache?: boolean + error?: string +} + +/** Electron resource types which map to different Chrome resource type names. */ +const RESOURCE_TYPE_MAP: { [key: string]: string } = { + mainFrame: 'main_frame', + subFrame: 'sub_frame', + xhr: 'xmlhttprequest', + cspReport: 'csp_report', + webSocket: 'websocket', +} + +const getResourceType = (resourceType: string) => RESOURCE_TYPE_MAP[resourceType] || resourceType + +const getFrameId = (frame: Electron.WebFrameMain | null | undefined) => + frame ? (frame === frame.top ? 0 : frame.frameTreeNodeId) : -1 + +const getParentFrameId = (frame: Electron.WebFrameMain | null | undefined) => { + const parentFrame = frame?.parent + return parentFrame ? getFrameId(parentFrame) : -1 +} + +/** Converts Electron request headers into Chrome's HttpHeader array. */ +const convertRequestHeaders = (headers: Record): chrome.webRequest.HttpHeader[] => { + return Object.entries(headers).map(([name, value]) => ({ name, value })) +} + +/** Converts Electron response headers into Chrome's HttpHeader array. */ +const convertResponseHeaders = ( + headers: Record, +): chrome.webRequest.HttpHeader[] => { + return Object.entries(headers).flatMap(([name, values]) => + values.map((value) => ({ name, value })), + ) +} + +/** + * Converts Electron upload data into Chrome's request body shape. + * + * NOTE: Only raw bytes are provided—Chrome's `formData` parsing is not + * implemented. + */ +const convertRequestBody = (uploadData: Electron.UploadData[]) => ({ + raw: uploadData.map((data) => ({ + bytes: data.bytes + ? data.bytes.buffer.slice( + data.bytes.byteOffset, + data.bytes.byteOffset + data.bytes.byteLength, + ) + : undefined, + file: data.file, + })), +}) + +/** Match patterns which grant host access from an extension's manifest. */ +const getHostPatterns = (extension: Electron.Extension): string[] => { + const manifest = getExtensionManifest(extension) + const permissions = + manifest.manifest_version === 3 ? manifest.host_permissions : manifest.permissions + return (permissions || []).filter( + (permission) => permission === '' || permission.includes('://'), + ) +} + +const canAccessUrl = (extension: Electron.Extension, url: string): boolean => { + const manifest = getExtensionManifest(extension) + if (!manifest.permissions?.includes('webRequest')) return false + return getHostPatterns(extension).some((pattern) => matchesPattern(pattern, url)) +} + +export class WebRequestAPI { + /** Number of extension listeners per event, keyed by extension ID. */ + private listeners: Map> = new Map() + + constructor(private ctx: ExtensionContext) { + const { router } = this.ctx + router.events.on('listener-added', this.onListenerAdded) + router.events.on('listener-removed', this.onListenerRemoved) + } + + private getEventFromName(eventName: string): WebRequestEventName | undefined { + const [apiName, event] = eventName.split('.') + if (apiName !== 'webRequest') return + // NOTE: 'onAuthRequired' is intentionally excluded—Electron provides no + // session.webRequest equivalent so it never emits. + return WEB_REQUEST_EVENTS.includes(event as WebRequestEventName) + ? (event as WebRequestEventName) + : undefined + } + + private onListenerAdded = (eventName: string, extensionId: string) => { + const event = this.getEventFromName(eventName) + if (!event) return + + let extensionCounts = this.listeners.get(event) + if (!extensionCounts) { + extensionCounts = new Map() + this.listeners.set(event, extensionCounts) + } + + const shouldAttach = extensionCounts.size === 0 + extensionCounts.set(extensionId, (extensionCounts.get(extensionId) || 0) + 1) + + if (shouldAttach) { + this.attachSessionListener(event) + } + } + + private onListenerRemoved = (eventName: string, extensionId: string) => { + const event = this.getEventFromName(eventName) + if (!event) return + + const extensionCounts = this.listeners.get(event) + if (!extensionCounts) return + + const count = extensionCounts.get(extensionId) || 0 + if (count > 1) { + extensionCounts.set(extensionId, count - 1) + } else { + extensionCounts.delete(extensionId) + } + + if (extensionCounts.size === 0) { + this.listeners.delete(event) + this.detachSessionListener(event) + } + } + + /** + * Attaches a session.webRequest listener for the given event. + * + * IMPORTANT: Registering any session.webRequest listener disables Chromium's + * built-in extension webRequest and declarativeNetRequest handling for new + * URLLoaderFactories in the session. To limit the impact, listeners are + * attached only while at least one extension listener exists and detached + * when the last one is removed. + */ + private attachSessionListener(event: WebRequestEventName) { + d(`attaching session listener for '${event}'`) + + const { webRequest } = this.ctx.session + + const listener = BLOCKING_EVENTS.has(event) + ? ( + details: ElectronRequestDetails, + callback: (response: Electron.CallbackResponse) => void, + ) => { + // Observational only: never block or modify the request. + callback({}) + this.onRequestEvent(event, details) + } + : (details: ElectronRequestDetails) => { + this.onRequestEvent(event, details) + } + + ;(webRequest[event] as any)(listener) + } + + private detachSessionListener(event: WebRequestEventName) { + d(`detaching session listener for '${event}'`) + const { webRequest } = this.ctx.session + ;(webRequest[event] as any)(null) + } + + /** Sends the event to each subscribed extension with access to the URL. */ + private onRequestEvent(event: WebRequestEventName, details: ElectronRequestDetails) { + const extensionCounts = this.listeners.get(event) + if (!extensionCounts || extensionCounts.size === 0) return + + const eventDetails = this.createEventDetails(event, details) + + const sessionExtensions = this.ctx.session.extensions || this.ctx.session + for (const extensionId of extensionCounts.keys()) { + const extension = sessionExtensions.getExtension(extensionId) + if (!extension) continue + + if (!canAccessUrl(extension, details.url)) { + d(`'${event}' not sent to ${extensionId}—no host access to ${details.url}`) + continue + } + + this.ctx.router.sendEvent(extensionId, `webRequest.${event}`, eventDetails) + } + } + + /** + * Maps Electron's listener details into Chrome's webRequest details. + * + * Headers and request bodies are always included when Electron provides + * them—the renderer preload is responsible for removing any which weren't + * requested by the listener's extraInfoSpec. + */ + private createEventDetails(event: WebRequestEventName, details: ElectronRequestDetails) { + const type = getResourceType(details.resourceType) + + // NOTE: frameId/parentFrameId are approximations based on Electron's + // frameTreeNodeId, consistent with the webNavigation implementation. + // Aside from the main frame's 0, they won't match Chrome's frame IDs. + let frameId = -1 + let parentFrameId = -1 + try { + frameId = type === 'main_frame' ? 0 : getFrameId(details.frame) + parentFrameId = type === 'main_frame' ? -1 : getParentFrameId(details.frame) + } catch { + // WebFrameMain may have been disposed. + } + + const tabId = + typeof details.webContentsId === 'number' && this.ctx.store.getTabById(details.webContentsId) + ? details.webContentsId + : -1 + + const eventDetails: Record = { + frameId, + method: details.method, + parentFrameId, + requestId: `${details.id}`, + tabId, + timeStamp: details.timestamp, + type, + url: details.url, + } + + switch (event) { + case 'onBeforeRequest': + if (details.uploadData && details.uploadData.length > 0) { + eventDetails.requestBody = convertRequestBody(details.uploadData) + } + break + case 'onBeforeSendHeaders': + case 'onSendHeaders': + eventDetails.requestHeaders = convertRequestHeaders(details.requestHeaders || {}) + break + case 'onHeadersReceived': + eventDetails.responseHeaders = convertResponseHeaders(details.responseHeaders || {}) + eventDetails.statusCode = details.statusCode + eventDetails.statusLine = details.statusLine + break + case 'onResponseStarted': + eventDetails.responseHeaders = convertResponseHeaders(details.responseHeaders || {}) + eventDetails.fromCache = details.fromCache + eventDetails.statusCode = details.statusCode + eventDetails.statusLine = details.statusLine + break + case 'onBeforeRedirect': + eventDetails.responseHeaders = convertResponseHeaders(details.responseHeaders || {}) + eventDetails.redirectUrl = details.redirectURL + eventDetails.fromCache = details.fromCache + eventDetails.statusCode = details.statusCode + eventDetails.statusLine = details.statusLine + if (details.ip) eventDetails.ip = details.ip + break + case 'onCompleted': + eventDetails.responseHeaders = convertResponseHeaders(details.responseHeaders || {}) + eventDetails.fromCache = details.fromCache + eventDetails.statusCode = details.statusCode + eventDetails.statusLine = details.statusLine + break + case 'onErrorOccurred': + eventDetails.error = details.error + eventDetails.fromCache = details.fromCache + break + } + + return eventDetails + } +} diff --git a/packages/electron-chrome-extensions/src/browser/index.ts b/packages/electron-chrome-extensions/src/browser/index.ts index d2e6a0a9..19f290c3 100644 --- a/packages/electron-chrome-extensions/src/browser/index.ts +++ b/packages/electron-chrome-extensions/src/browser/index.ts @@ -8,6 +8,7 @@ import { BrowserActionAPI } from './api/browser-action' import { TabsAPI } from './api/tabs' import { WindowsAPI } from './api/windows' import { WebNavigationAPI } from './api/web-navigation' +import { WebRequestAPI } from './api/web-request' import { ExtensionStore } from './store' import { ContextMenusAPI } from './api/context-menus' import { RuntimeAPI } from './api/runtime' @@ -130,6 +131,7 @@ export class ElectronChromeExtensions extends EventEmitter { runtime: RuntimeAPI tabs: TabsAPI webNavigation: WebNavigationAPI + webRequest: WebRequestAPI windows: WindowsAPI } @@ -167,6 +169,7 @@ export class ElectronChromeExtensions extends EventEmitter { runtime: new RuntimeAPI(this.ctx), tabs: new TabsAPI(this.ctx), webNavigation: new WebNavigationAPI(this.ctx), + webRequest: new WebRequestAPI(this.ctx), windows: new WindowsAPI(this.ctx), } diff --git a/packages/electron-chrome-extensions/src/renderer/index.ts b/packages/electron-chrome-extensions/src/renderer/index.ts index 78770e54..7a4a07c2 100644 --- a/packages/electron-chrome-extensions/src/renderer/index.ts +++ b/packages/electron-chrome-extensions/src/renderer/index.ts @@ -156,6 +156,132 @@ export const injectExtensionAPIs = () => { } } + /** + * Matches a URL against a Chrome extension match pattern. + * + * @see https://developer.chrome.com/docs/extensions/develop/concepts/match-patterns + */ + const matchesUrlPattern = (pattern: string, url: string): boolean => { + if (pattern === '') return true + + const patternParts = /^(\*|[a-z][a-z0-9+.-]*):\/\/(\*|(?:\*\.)?[^/*]+|)(\/.*)$/.exec(pattern) + if (!patternParts) return false + const [, patternScheme, patternHost, patternPath] = patternParts + + let urlParts: URL + try { + urlParts = new URL(url) + } catch { + return false + } + + const scheme = urlParts.protocol.slice(0, -1) + if (patternScheme === '*') { + if (scheme !== 'http' && scheme !== 'https') return false + } else if (scheme !== patternScheme) { + return false + } + + const host = urlParts.hostname + if (patternHost !== '*') { + if (patternHost.startsWith('*.')) { + const domain = patternHost.slice(2) + if (host !== domain && !host.endsWith(`.${domain}`)) return false + } else if (host !== patternHost) { + return false + } + } + + const escapePattern = (subpattern: string) => subpattern.replace(/[\\^$+?.()|[\]{}]/g, '\\$&') + const pathRegExp = new RegExp(`^${patternPath.split('*').map(escapePattern).join('.*')}$`) + return pathRegExp.test(urlParts.pathname + urlParts.search) + } + + interface WebRequestDetails { + url: string + type: string + tabId: number + requestHeaders?: unknown + responseHeaders?: unknown + requestBody?: unknown + } + + const matchesRequestFilter = ( + details: WebRequestDetails, + filter?: chrome.webRequest.RequestFilter, + ): boolean => { + if (!filter) return true + if ( + filter.urls && + filter.urls.length > 0 && + !filter.urls.some((pattern) => matchesUrlPattern(pattern, details.url)) + ) { + return false + } + if (filter.types && filter.types.length > 0 && !filter.types.includes(details.type as any)) { + return false + } + // NOTE: filter.windowId is not supported. + if (typeof filter.tabId === 'number' && details.tabId !== filter.tabId) { + return false + } + return true + } + + /** + * Event type for chrome.webRequest events. + * + * Listener callbacks are filtered locally based on the provided + * RequestFilter and extraInfoSpec. + * + * NOTE: This implementation is observational only. The 'blocking' + * extraInfoSpec is ignored and any value returned by listeners has no + * effect on the request. + */ + class WebRequestEvent { + private listenerMap = new Map() + + constructor(private name: string) {} + + addListener( + callback: Function, + filter?: chrome.webRequest.RequestFilter, + extraInfoSpec: string[] = [], + ) { + const listener = (details: WebRequestDetails) => { + if (!matchesRequestFilter(details, filter)) return + + // Headers and request bodies are always sent by the main process. + // Remove any which weren't requested by the extraInfoSpec. + // NOTE: 'blocking' and 'extraHeaders' are intentionally ignored. + const listenerDetails = { ...details } + if (!extraInfoSpec.includes('requestHeaders')) delete listenerDetails.requestHeaders + if (!extraInfoSpec.includes('responseHeaders')) delete listenerDetails.responseHeaders + if (!extraInfoSpec.includes('requestBody')) delete listenerDetails.requestBody + + callback(listenerDetails) + } + + this.listenerMap.set(callback, listener) + electron.addExtensionListener(extensionId, this.name, listener) + } + + removeListener(callback: Function) { + const listener = this.listenerMap.get(callback) + if (!listener) return + this.listenerMap.delete(callback) + electron.removeExtensionListener(extensionId, this.name, listener) + } + + hasListener(callback: Function) { + return this.listenerMap.has(callback) + } + + hasListeners() { + return this.listenerMap.size > 0 + } + } + // chrome.types.ChromeSetting class ChromeSetting { set() {} @@ -615,7 +741,24 @@ export const injectExtensionAPIs = () => { factory: (base) => { return { ...base, - onHeadersReceived: new ExtensionEvent('webRequest.onHeadersReceived'), + handlerBehaviorChanged: (callback?: () => void) => { + // Observational implementation without an in-memory cache to + // invalidate. Provided for API compatibility. + if (callback) queueMicrotask(callback) + return Promise.resolve() + }, + MAX_HANDLER_BEHAVIOR_CHANGED_CALLS_PER_10_MINUTES: 20, + // NOTE: onAuthRequired is registered for API compatibility, but + // never emits—Electron provides no session.webRequest equivalent. + onAuthRequired: new WebRequestEvent('webRequest.onAuthRequired'), + onBeforeRedirect: new WebRequestEvent('webRequest.onBeforeRedirect'), + onBeforeRequest: new WebRequestEvent('webRequest.onBeforeRequest'), + onBeforeSendHeaders: new WebRequestEvent('webRequest.onBeforeSendHeaders'), + onCompleted: new WebRequestEvent('webRequest.onCompleted'), + onErrorOccurred: new WebRequestEvent('webRequest.onErrorOccurred'), + onHeadersReceived: new WebRequestEvent('webRequest.onHeadersReceived'), + onResponseStarted: new WebRequestEvent('webRequest.onResponseStarted'), + onSendHeaders: new WebRequestEvent('webRequest.onSendHeaders'), } }, }, From e060471e03cdfdf0b04beb4726e6d341a8493c06 Mon Sep 17 00:00:00 2001 From: dep Date: Sun, 5 Jul 2026 07:34:22 -0400 Subject: [PATCH 3/4] test: chrome.webRequest events, filtering, and lazy session listeners Adds a chrome-webRequest fixture extension which records received events and a spec covering: - onBeforeRequest/onCompleted details for page loads - subresource (fetch) events - RequestFilter URL filtering - header visibility gated on extraInfoSpec - lazy attach/detach of session.webRequest listeners Co-Authored-By: Claude Fable 5 --- .../spec/chrome-webRequest-spec.ts | 196 ++++++++++++++++++ .../fixtures/chrome-webRequest/background.js | 54 +++++ .../chrome-webRequest/content-script.js | 35 ++++ .../fixtures/chrome-webRequest/manifest.json | 17 ++ 4 files changed, 302 insertions(+) create mode 100644 packages/electron-chrome-extensions/spec/chrome-webRequest-spec.ts create mode 100644 packages/electron-chrome-extensions/spec/fixtures/chrome-webRequest/background.js create mode 100644 packages/electron-chrome-extensions/spec/fixtures/chrome-webRequest/content-script.js create mode 100644 packages/electron-chrome-extensions/spec/fixtures/chrome-webRequest/manifest.json diff --git a/packages/electron-chrome-extensions/spec/chrome-webRequest-spec.ts b/packages/electron-chrome-extensions/spec/chrome-webRequest-spec.ts new file mode 100644 index 00000000..251d5df2 --- /dev/null +++ b/packages/electron-chrome-extensions/spec/chrome-webRequest-spec.ts @@ -0,0 +1,196 @@ +import * as path from 'node:path' +import { expect } from 'chai' +import { ipcMain } from 'electron' + +import { ElectronChromeExtensions } from '../' +import { emittedOnce } from './events-helpers' +import { createCrxSession, waitForBackgroundScriptEvaluated } from './crx-helpers' +import { useExtensionBrowser, useServer } from './hooks' + +interface WebRequestLogEntry { + listenerId: string + details: any +} + +interface SessionListenerCall { + event: string + listener: string +} + +const SESSION_WEB_REQUEST_EVENTS = [ + 'onBeforeRequest', + 'onBeforeSendHeaders', + 'onSendHeaders', + 'onHeadersReceived', + 'onResponseStarted', + 'onBeforeRedirect', + 'onCompleted', + 'onErrorOccurred', +] + +/** session.webRequest events used by the chrome-webRequest fixture, sorted. */ +const FIXTURE_ATTACHED_EVENTS = ['onBeforeRequest', 'onCompleted', 'onSendHeaders'] + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +const waitUntil = async (predicate: () => boolean) => { + for (let attempt = 0; attempt < 30; attempt++) { + if (predicate()) return + await sleep(100) + } + throw new Error('Timed out waiting for condition') +} + +/** Records (de)registration of session.webRequest listeners. */ +const observeSessionWebRequest = (session: Electron.Session) => { + const calls: SessionListenerCall[] = [] + const webRequest: any = session.webRequest + for (const event of SESSION_WEB_REQUEST_EVENTS) { + const original = webRequest[event].bind(webRequest) + webRequest[event] = (listener: any) => { + calls.push({ event, listener: listener === null ? 'null' : typeof listener }) + return original(listener) + } + } + return calls +} + +describe('chrome.webRequest', () => { + const server = useServer() + const browser = useExtensionBrowser({ url: server.getUrl, extensionName: 'chrome-webRequest' }) + + const sendToBackground = async (message: { type: string }) => { + const p = emittedOnce(ipcMain, 'success') + await browser.webContents.executeJavaScript(`exec('${JSON.stringify(message)}')`) + const [, result] = await p + return result + } + + const getLog = (): Promise => + sendToBackground({ type: 'get-webrequest-log' }) + + const waitForLog = async (predicate: (log: WebRequestLogEntry[]) => boolean) => { + let log: WebRequestLogEntry[] = [] + for (let attempt = 0; attempt < 30; attempt++) { + log = await getLog() + if (predicate(log)) return log + await sleep(100) + } + throw new Error(`Timed out waiting for webRequest log. Received: ${JSON.stringify(log)}`) + } + + const findEntry = (log: WebRequestLogEntry[], listenerId: string, url: string) => + log.find((entry) => entry.listenerId === listenerId && entry.details.url === url) + + it('emits onBeforeRequest and onCompleted for page loads', async () => { + const url = server.getUrl() + const log = await waitForLog( + (log) => !!findEntry(log, 'onBeforeRequest', url) && !!findEntry(log, 'onCompleted', url), + ) + + const beforeRequest = findEntry(log, 'onBeforeRequest', url)!.details + expect(beforeRequest.url).to.equal(url) + expect(beforeRequest.method).to.equal('GET') + expect(beforeRequest.type).to.equal('main_frame') + expect(beforeRequest.tabId).to.equal(browser.webContents.id) + expect(beforeRequest.frameId).to.equal(0) + expect(beforeRequest.parentFrameId).to.equal(-1) + expect(beforeRequest.requestId).to.be.a('string') + expect(beforeRequest.timeStamp).to.be.a('number') + + const completed = findEntry(log, 'onCompleted', url)!.details + expect(completed.statusCode).to.equal(200) + expect(completed.requestId).to.equal(beforeRequest.requestId) + }) + + it('emits events for subresource requests', async () => { + const url = `${server.getUrl()}sub-resource` + await browser.webContents.executeJavaScript(`void fetch('${url}')`) + + const log = await waitForLog((log) => !!findEntry(log, 'onBeforeRequest', url)) + + const beforeRequest = findEntry(log, 'onBeforeRequest', url)!.details + expect(beforeRequest.type).to.equal('xmlhttprequest') + expect(beforeRequest.method).to.equal('GET') + expect(beforeRequest.tabId).to.equal(browser.webContents.id) + }) + + it('does not emit events for URLs excluded by the listener filter', async () => { + const url = server.getUrl() + const log = await waitForLog((log) => !!findEntry(log, 'onCompleted', url)) + + const filteredEntries = log.filter((entry) => entry.listenerId === 'onBeforeRequestFiltered') + expect(filteredEntries).to.be.empty + }) + + it('includes headers only when requested by extraInfoSpec', async () => { + const url = server.getUrl() + const log = await waitForLog( + (log) => + !!findEntry(log, 'onCompleted', url) && + !!findEntry(log, 'onCompletedWithHeaders', url) && + !!findEntry(log, 'onSendHeaders', url), + ) + + const completed = findEntry(log, 'onCompleted', url)!.details + expect(completed.responseHeaders).to.be.undefined + + const completedWithHeaders = findEntry(log, 'onCompletedWithHeaders', url)!.details + expect(completedWithHeaders.responseHeaders).to.be.an('array') + const contentType = completedWithHeaders.responseHeaders.find( + (header: any) => header.name.toLowerCase() === 'content-type', + ) + expect(contentType?.value).to.equal('text/html') + + const sendHeaders = findEntry(log, 'onSendHeaders', url)!.details + expect(sendHeaders.requestHeaders).to.be.an('array') + expect(sendHeaders.requestHeaders[0].name).to.be.a('string') + expect(sendHeaders.requestHeaders[0].value).to.be.a('string') + }) + + it('detaches session listeners when extension listeners are removed', async () => { + const calls = observeSessionWebRequest(browser.session) + + await sendToBackground({ type: 'remove-webrequest-listeners' }) + + await waitUntil(() => calls.length >= FIXTURE_ATTACHED_EVENTS.length) + expect(calls.map((call) => call.event).sort()).to.deep.equal(FIXTURE_ATTACHED_EVENTS) + expect(calls.every((call) => call.listener === 'null')).to.be.true + }) +}) + +describe('chrome.webRequest session listener attachment', () => { + const fixtures = path.join(__dirname, 'fixtures') + + it('only attaches session listeners for events with extension listeners', async () => { + const { session: customSession } = createCrxSession() + const calls = observeSessionWebRequest(customSession) + + new ElectronChromeExtensions({ + license: 'internal-license-do-not-use' as any, + session: customSession, + }) + + const sessionExtensions = customSession.extensions || customSession + + // An extension without webRequest listeners must not attach any + // session.webRequest listeners which would disable Chromium's built-in + // extension webRequest and declarativeNetRequest handling. + const rpcExtension = await sessionExtensions.loadExtension(path.join(fixtures, 'rpc')) + await waitForBackgroundScriptEvaluated(rpcExtension, customSession) + await sleep(100) + expect(calls).to.be.empty + + // The chrome-webRequest fixture listens for onBeforeRequest, + // onSendHeaders, onCompleted and onAuthRequired. Only the events + // supported by session.webRequest should be attached. + const extension = await sessionExtensions.loadExtension( + path.join(fixtures, 'chrome-webRequest'), + ) + await waitForBackgroundScriptEvaluated(extension, customSession) + + await waitUntil(() => calls.length >= FIXTURE_ATTACHED_EVENTS.length) + expect(calls.map((call) => call.event).sort()).to.deep.equal(FIXTURE_ATTACHED_EVENTS) + expect(calls.every((call) => call.listener === 'function')).to.be.true + }) +}) diff --git a/packages/electron-chrome-extensions/spec/fixtures/chrome-webRequest/background.js b/packages/electron-chrome-extensions/spec/fixtures/chrome-webRequest/background.js new file mode 100644 index 00000000..6852057b --- /dev/null +++ b/packages/electron-chrome-extensions/spec/fixtures/chrome-webRequest/background.js @@ -0,0 +1,54 @@ +/* global chrome */ + +const log = [] +const registrations = [] + +const record = (listenerId) => (details) => { + log.push({ listenerId, details }) +} + +const listen = (event, listenerId, filter, extraInfoSpec) => { + const callback = record(listenerId) + event.addListener(callback, filter, extraInfoSpec) + registrations.push([event, callback]) +} + +listen(chrome.webRequest.onBeforeRequest, 'onBeforeRequest', { urls: [''] }) + +// Should never fire for URLs served by the spec's HTTP server. +listen(chrome.webRequest.onBeforeRequest, 'onBeforeRequestFiltered', { + urls: ['*://no-match.invalid/*'], +}) + +listen(chrome.webRequest.onSendHeaders, 'onSendHeaders', { urls: [''] }, [ + 'requestHeaders', +]) + +listen(chrome.webRequest.onCompleted, 'onCompleted', { urls: [''] }) + +listen(chrome.webRequest.onCompleted, 'onCompletedWithHeaders', { urls: [''] }, [ + 'responseHeaders', +]) + +// Never emits, but must not throw on registration. +listen(chrome.webRequest.onAuthRequired, 'onAuthRequired') + +chrome.runtime.onMessage.addListener((message, sender, reply) => { + switch (message && message.type) { + case 'get-webrequest-log': + reply(log) + break + + case 'remove-webrequest-listeners': + registrations.forEach(([event, callback]) => { + event.removeListener(callback) + }) + reply(true) + break + } + + // Respond asynchronously + return true +}) + +console.log('background-script-evaluated') diff --git a/packages/electron-chrome-extensions/spec/fixtures/chrome-webRequest/content-script.js b/packages/electron-chrome-extensions/spec/fixtures/chrome-webRequest/content-script.js new file mode 100644 index 00000000..074991ec --- /dev/null +++ b/packages/electron-chrome-extensions/spec/fixtures/chrome-webRequest/content-script.js @@ -0,0 +1,35 @@ +/* eslint-disable */ + +function evalInMainWorld(fn) { + const script = document.createElement('script') + script.textContent = `((${fn})())` + document.documentElement.appendChild(script) +} + +function sendIpc(name, ...args) { + const jsonArgs = [name, ...args].map((arg) => JSON.stringify(arg)) + const funcStr = `() => { electronTest.sendIpc(${jsonArgs.join(', ')}) }` + evalInMainWorld(funcStr) +} + +async function exec(action) { + const result = await new Promise((resolve, reject) => { + chrome.runtime.sendMessage(action, (result) => { + if (chrome.runtime.lastError) { + reject(chrome.runtime.lastError.message) + } else { + resolve(result) + } + }) + }) + + sendIpc('success', result) +} + +window.addEventListener('message', (event) => { + exec(event.data) +}) + +evalInMainWorld(() => { + window.exec = (json) => window.postMessage(JSON.parse(json)) +}) diff --git a/packages/electron-chrome-extensions/spec/fixtures/chrome-webRequest/manifest.json b/packages/electron-chrome-extensions/spec/fixtures/chrome-webRequest/manifest.json new file mode 100644 index 00000000..2bf331d6 --- /dev/null +++ b/packages/electron-chrome-extensions/spec/fixtures/chrome-webRequest/manifest.json @@ -0,0 +1,17 @@ +{ + "name": "chrome-webRequest", + "version": "1.0", + "manifest_version": 2, + "content_scripts": [ + { + "matches": [""], + "js": ["content-script.js"], + "run_at": "document_end" + } + ], + "background": { + "scripts": ["background.js"], + "persistent": true + }, + "permissions": ["webRequest", ""] +} From 25521ee2be68cbaabe524a7b8f640e0d75ff5802 Mon Sep 17 00:00:00 2001 From: dep Date: Sun, 5 Jul 2026 07:55:40 -0400 Subject: [PATCH 4/4] fix: inject chrome.* API polyfills into the native browser namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chromium now exposes a native `browser` global in extension contexts as a distinct object from `chrome`. Cross-browser extensions commonly resolve `globalThis.browser || globalThis.chrome`, so they previously saw only the unpatched native APIs. Inject the same API objects into both namespaces. `browser` is deliberately not frozen: Chromium leaves it configurable, and extensions that wrap it in a Proxy returning substitute objects from get() would violate the proxy invariant for frozen data properties. Also make ChromeSetting stubs return Chrome-shaped results (value + levelOfControl: 'not_controllable') as promises or via callback — the previous bare stubs returned undefined, crashing extensions that chain .then() on privacy settings (observed with NordPass, which aborts its initialization before registering its action.onClicked listener). --- .../src/renderer/index.ts | 52 ++++++++++++++++--- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/packages/electron-chrome-extensions/src/renderer/index.ts b/packages/electron-chrome-extensions/src/renderer/index.ts index 7a4a07c2..af793d06 100644 --- a/packages/electron-chrome-extensions/src/renderer/index.ts +++ b/packages/electron-chrome-extensions/src/renderer/index.ts @@ -284,11 +284,33 @@ export const injectExtensionAPIs = () => { // chrome.types.ChromeSetting class ChromeSetting { - set() {} - get() {} - clear() {} + // Electron doesn't back these preferences, so report them as out of the + // extension's control. Must return promises (or invoke callbacks) — + // extensions chain on them (e.g. `get({}).then(...)`). + get(details?: any, callback?: any) { + if (typeof details === 'function') callback = details + const result = { value: false, levelOfControl: 'not_controllable' } + if (callback) { + queueMicrotask(() => callback(result)) + } + return Promise.resolve(result) + } + set(details?: any, callback?: any) { + if (callback) { + queueMicrotask(() => callback()) + } + return Promise.resolve() + } + clear(details?: any, callback?: any) { + if (callback) { + queueMicrotask(() => callback()) + } + return Promise.resolve() + } onChange = { addListener: () => {}, + removeListener: () => {}, + hasListener: () => false, } } @@ -785,6 +807,14 @@ export const injectExtensionAPIs = () => { }, } + // Chromium may expose a native `browser` global aliasing the same APIs as + // `chrome` but as a distinct object. Cross-browser extensions commonly + // prefer it (`globalThis.browser || globalThis.chrome`), so inject into + // both or those extensions would see only the unpatched native APIs. + const namespaces = [chrome, (globalThis as any).browser].filter( + (ns) => typeof ns === 'object' && ns !== null, + ) + // Initialize APIs Object.keys(apiDefinitions).forEach((key: any) => { const apiName: keyof typeof chrome = key @@ -794,16 +824,22 @@ export const injectExtensionAPIs = () => { // Allow APIs to opt-out of being available in this context. if (api.shouldInject && !api.shouldInject()) return - Object.defineProperty(chrome, apiName, { - value: api.factory(baseApi), - enumerable: true, - configurable: true, - }) + const value = api.factory(baseApi) + for (const ns of namespaces) { + Object.defineProperty(ns, apiName, { + value, + enumerable: true, + configurable: true, + }) + } }) // Remove access to internals delete (globalThis as any).electron + // Freeze `chrome` only. Chromium leaves `browser` configurable, and + // extensions that wrap it in a Proxy (returning substitute objects from + // `get`) would violate the proxy invariant for frozen properties. Object.freeze(chrome) void 0 // no return