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", ""] +} 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/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) { diff --git a/packages/electron-chrome-extensions/src/renderer/index.ts b/packages/electron-chrome-extensions/src/renderer/index.ts index 78770e54..af793d06 100644 --- a/packages/electron-chrome-extensions/src/renderer/index.ts +++ b/packages/electron-chrome-extensions/src/renderer/index.ts @@ -156,13 +156,161 @@ 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() {} - 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, } } @@ -615,7 +763,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'), } }, }, @@ -642,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 @@ -651,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