Skip to content

feat: observational chrome.webRequest support#183

Open
dep wants to merge 4 commits into
samuelmaddock:masterfrom
dep:feat/webrequest-observational
Open

feat: observational chrome.webRequest support#183
dep wants to merge 4 commits into
samuelmaddock:masterfrom
dep:feat/webrequest-observational

Conversation

@dep

@dep dep commented Jul 5, 2026

Copy link
Copy Markdown

What

Implements observational chrome.webRequest support: a main-process WebRequestAPI bridging Electron's session.webRequest to extension contexts, and a full preload surface replacing the previous onHeadersReceived-only stub (which registered listeners that never fired).

Why

MV3 extensions that observe network activity (password managers, privacy tools) currently crash at startup in Electron-based browsers: Electron registers the webRequest schema but the API is non-functional in extension contexts, so chrome.webRequest.onBeforeRequest is undefined, the service worker throws, and MV3 worker registration fails outright. Context and root-cause analysis in electron/electron#52265 — notably, even on Electron main (where the Electron 43 bindings-pak regression is fixed), webRequest events are never dispatched to MV3 service workers; MV2 background pages work. This PR makes the polyfill cover the gap for both.

Verified end-to-end on Electron 43: an MV3 extension whose worker calls chrome.webRequest.onBeforeRequest.addListener(...) at top level — previously an instant crash ("Service worker registration failed. Status code: 15") — now registers and receives events for page loads.

Design notes

  • Observational only. Electron's blocking-capable events have their callback resolved with {} immediately, before fan-out. 'blocking'/'extraHeaders' in extraInfoSpec are silently ignored, and listener return values have no effect. (MV3 Chrome itself restricts blocking webRequest to policy installs, so observational is the meaningful MV3 surface.)
  • Lazy attach/detach. Because a single session.webRequest listener disables Chromium's native extension webRequest and declarativeNetRequest enforcement for new URLLoaderFactories (Electron's exclusivity rule), WebRequestAPI attaches a session listener per event only on the 0→1 extension-listener transition and detaches on the last removal. Sessions/events with no webRequest listeners are untouched, so dNR-only extensions (e.g. uBO Lite dynamic rules) keep native enforcement. Implemented via new listener-added/listener-removed events on ExtensionRouter.
  • Per-extension fan-out with permission gating. Events are sent per extension via router.sendEvent (never broadcast), gated on the webRequest manifest permission plus host-pattern matching (host_permissions MV3 / permissions MV2, <all_urls>), so URLs don't leak to extensions without access. Service-worker delivery reuses the router's existing startWorkerForScope wake-up.
  • Details mapping. requestId, Chrome resource-type names (xhrxmlhttprequest, etc.), headers as [{name, value}], requestBody.raw from uploadData, statusCode/statusLine/fromCache/ip/redirectUrl/error where Electron provides them; tabId is -1 for untracked webContents; frameId/parentFrameId approximated from frameTreeNodeId (consistent with web-navigation.ts).
  • Preload applies RequestFilter (urls via Chrome match-pattern semantics, types, tabId) and strips requestHeaders/responseHeaders/requestBody unless requested via extraInfoSpec. handlerBehaviorChanged is an async no-op. onAuthRequired is registrable but never emits (no session.webRequest equivalent).

Known limitations vs Chrome

  • No blocking/modification; no onAuthRequired events.
  • While any extension listens to a webRequest event, native extension webRequest and dNR enforcement are disabled for new loader factories in that session (Electron exclusivity; lazy attach minimizes exposure).
  • No initiator, documentId, documentLifecycle, frameType; no formData parsing; filter.windowId unsupported; runtime-granted optional host permissions not consulted.

Tests

New spec/chrome-webRequest-spec.ts + spec/fixtures/chrome-webRequest/: 6 specs covering event details (url/method/type/tabId/frameId/requestId), subresource typing, URL filtering, extraInfoSpec header gating, detach-on-removal, and lazy attach (no session listeners without extension listeners). Full package suite: 70 passing, 0 failing (2 pre-existing pending). build (tsc + esbuild) clean.

🤖 Generated with Claude Code

dep and others added 4 commits July 5, 2026 07:33
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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).
@dep

dep commented Jul 5, 2026

Copy link
Copy Markdown
Author

Pushed a follow-up commit after validating against a real-world extension (NordPass, MV3, worker-driven action button):

  • Native browser namespace: Chromium now exposes a native browser global in extension contexts as a distinct object from chrome. Extensions resolving globalThis.browser || globalThis.chrome saw only the unpatched native APIs (memberless webRequest, etc.). The preload now injects the same API objects into both namespaces. browser is intentionally left unfrozen — Chromium leaves it configurable, and extensions that wrap it in a Proxy returning substitute objects from get() (NordPass does) would hit the proxy invariant for frozen data properties.
  • ChromeSetting stubs: get/set/clear previously returned undefined, crashing promise-style callers (privacy.services.passwordSavingEnabled.get({}).then(...)). They now return Chrome-shaped results ({ value, levelOfControl: 'not_controllable' }) as promises or via callback.

With these plus the webRequest backend, NordPass's MV3 service worker goes from crashing at startup to fully initializing — registering action.onClicked and calling action.setPopup — on Electron 43.

@dep dep marked this pull request as ready for review July 5, 2026 12:02
@Anutim

Anutim commented Jul 5, 2026

Copy link
Copy Markdown

This breaks adblockers somewhat unfortunately, tested on Electron 41 and 43 against ads like Adchoice (with Adguard/uBlock/uBlock Lite).
In my experience this happens in general with Electron when you use the webRequest events.

The website I'm testing with for issues is spriters-resource.com, Adchoice is shoved in everywhere there.

@Anutim

Anutim commented Jul 5, 2026

Copy link
Copy Markdown

I should clarify that I did try with uBlock Lite in Electron 43 with and without the PR (and it successfully blocking the above mentioned site's ads only without the PR), since you mentioned that adblocker in your first post.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants