CVE-2026-12300
Overview
Background
- `PageExtractorParent`
- a
JSWindowActorParentin thepageextractorcomponent that drives a headless<browser>to extract content from a remote page. - `HiddenBrowserManager`
- a
toolkitsingleton (inHiddenFrame.sys.mjs) that creates and owns the lifetime of off-screen hidden<browser>elements viawithHiddenBrowser. - `messagemanagergroup`
- a
<browser>attribute that binds the browser’s frameloader to a named message-manager group so aJSWindowActorcan be scoped to it. - `nodefaultsrc`
- a
<browser>attribute that suppresses the automatic initialabout:blankdocument load when the frameloader is first created.
Root Cause Analysis
The vulnerable path is PageExtractorParent’s call into lazy.HiddenBrowserManager.withHiddenBrowser, where the hidden <browser> was created first and only afterward mutated inside the async callback by browser.setAttribute("messagemanagergroup", "headless-browsers"). Because the browser was constructed with a default source, its frameloader eagerly loaded about:blank before the messagemanagergroup attribute (and remoteness) were reconciled, so the onLocationChange listener had to special-case and skip that initial about:blank navigation. Changing the message-manager group and remoteness after a frameloader already existed violated the invariant that a browser’s message-manager group must be fixed at frameloader-creation time, leaving a window where the frameloader/WindowGlobal state was inconsistent with the actor registration and could be operated on in a stale state.
The fix passes messageManagerGroup: "headless-browsers" as a creation option to withHiddenBrowser and adds nodefaultsrc="true" in HiddenBrowserManager, so the browser is built once with the correct group and with no premature about:blank load. With no default source, the about:blank guard in onLocationChange is no longer needed and was removed. This works because the frameloader now comes into existence already in the right group, eliminating the mismatched-state window.
<browser>’s message-manager group after its frameloader had already been created and had begun a default about:blank load, rather than setting the group at construction; the fix moves the group into the creation options and disables the default source so the frameloader is correct from birth.Attack Path
- Trigger extraction
Content that can reach the
PageExtractorfeature causesPageExtractorParentto open a headless browser for anhttp:/https:host. - Race the default load
The freshly created browser begins its default
about:blankload beforemessagemanagergroupand remoteness are applied inside the callback. - Group/frameloader mismatch
During that window the frameloader and
WindowGlobalexist in a state inconsistent with the intended message-manager group and actor registration. - Operate on stale state
onLocationChangeproceeds to resolve and use an actor/browsingContextwhose underlying frameloader state was reconstructed out from under it, reaching a freed or mismatched object.
Impact Assessment
HiddenBrowserManager and the PageExtractorParent actor, since the hidden browser runs with the system principal. Exploitation requires the PageExtractor code path to be exercised against a remote host, and the corruption arises from the transient frameloader-remoteness/message-group race rather than attacker-controlled page content directly. No CVSS or additional exploit primitive is asserted in the metadata beyond the “memory safety bug” classification.Changed Functions
| Function | Change | Notes |
|---|---|---|
onLocationChangetoolkit/components/pageextractor/PageExtractorParent.sys.mjs |
modified | |
iftoolkit/components/pageextractor/PageExtractorParent.sys.mjs |
modified | |
iftoolkit/modules/HiddenFrame.sys.mjs |
modified |
Files Changed
toolkit/components/pageextractor/PageExtractorParent.sys.mjstoolkit/modules/HiddenFrame.sys.mjs
Audit Directions
- Post-creation remoteness/group mutationFlag any code that sets
messagemanagergroupormaychangeremotenesson a<browser>after it has been inserted and its frameloader may already exist; require these to be fixed at creation. - Default-source frameloadersAudit hidden/headless
<browser>creation for missingnodefaultsrc, since an eager defaultabout:blankload can race intended remoteness/group setup and force fragile navigation guards. - Actor lookup on in-flux contextsReview
getActor/currentWindowGlobalusage insideonLocationChangeand similar listeners to ensure thebrowsingContextframeloader is stable before the actor is resolved and used.
Patch
diff --git a/toolkit/components/pageextractor/PageExtractorParent.sys.mjs b/toolkit/components/pageextractor/PageExtractorParent.sys.mjs
index d84f7737463..7a6468d53e2 100644
--- a/toolkit/components/pageextractor/PageExtractorParent.sys.mjs
+++ b/toolkit/components/pageextractor/PageExtractorParent.sys.mjs
@@ -128,87 +128,90 @@ export class PageExtractorParent extends JSWindowActorParent {
throw new Error("Only http: and https: URLs are supported.");
}
// The hidden browser manager controls the lifetime of the hidden browser.
- return lazy.HiddenBrowserManager.withHiddenBrowser(async browser => {
- const { host } = url;
- // Create a custom message manager group for this browser so that the PageExtractor
- // actor can communicate with it. The actor is registered to use this custom
- // message manager group.
- browser.setAttribute("messagemanagergroup", "headless-browsers");
-
- /** @type {PromiseWithResolvers<PageExtractorParent>} */
- let actorResolver = Promise.withResolvers();
-
- const locationChangeFlags = Ci.nsIWebProgress.NOTIFY_LOCATION;
- const onLocationChange = {
- QueryInterface: ChromeUtils.generateQI([
- "nsIWebProgressListener",
- "nsISupportsWeakReference",
- ]),
- /**
- * @param {nsIWebProgress} webProgress
- * @param {nsIRequest} _request
- * @param {nsIURI} location
- * @param {number} _flags
- */
- onLocationChange(webProgress, _request, location, _flags) {
- if (!webProgress.isTopLevel) {
- lazy.console.log(
- "Headless browser had a non-top level location change."
- );
- return;
- }
- if (location.spec == "about:blank") {
- // about:blank is loaded first before loading the actual page.
- return;
- }
- if (URL.fromURI(location).host != host) {
- lazy.console.log(
- "A location change happened that wasn't the host.",
- location.host,
- host
- );
- // This is probably overkill, but make sure this is not a spurious
- // redirect.
- return;
- }
- browser.removeProgressListener(onLocationChange, locationChangeFlags);
-
- /** @type {any} - This is reported as an `Element`, but it's a <browser> */
- const topBrowser = webProgress.browsingContext.topFrameElement;
-
- try {
- const actor =
- topBrowser.browsingContext.currentWindowGlobal.getActor(
- "PageExtractor"
+ return lazy.HiddenBrowserManager.withHiddenBrowser(
+ async browser => {
+ const { host } = url;
+
+ /** @type {PromiseWithResolvers<PageExtractorParent>} */
+ let actorResolver = Promise.withResolvers();
+
+ const locationChangeFlags = Ci.nsIWebProgress.NOTIFY_LOCATION;
+ const onLocationChange = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsISupportsWeakReference",
+ ]),
+ /**
+ * @param {nsIWebProgress} webProgress
+ * @param {nsIRequest} _request
+ * @param {nsIURI} location
+ * @param {number} _flags
+ */
+ onLocationChange(webProgress, _request, location, _flags) {
+ if (!webProgress.isTopLevel) {
+ lazy.console.log(
+ "Headless browser had a non-top level location change."
);
-
- actor.waitForPageReady().then(() => {
- lazy.console.log("Headless PageExtractor is ready", url);
- actorResolver.resolve(actor);
- });
- } catch (error) {
- // TODO (Bug 2001385) - It would be nice to catch if this is the
- // `about:neterror` page or other similar errors. This will also fail if you
- // try to access something like `about:reader` with the same error.
- actorResolver.reject(
- new Error(
- "PageExtractor could not run on that page or the page could not be found."
- )
+ return;
+ }
+ if (URL.fromURI(location).host != host) {
+ lazy.console.log(
+ "A location change happened that wasn't the host.",
+ location.host,
+ host
+ );
+ // This is probably overkill, but make sure this is not a spurious
+ // redirect.
+ return;
+ }
+ browser.removeProgressListener(
+ onLocationChange,
+ locationChangeFlags
);
- }
- },
- };
-
- browser.addProgressListener(onLocationChange, locationChangeFlags);
- lazy.console.log("Loading a headless PageExtractor", url);
-
- browser.loadURI(url.URI, {
- triggeringPrincipal:
- Services.scriptSecurityManager.getSystemPrincipal(),
- });
-
- return callback(await actorResolver.promise);
- });
+ /** @type {any} - This is reported as an `Element`, but it's a <browser> */
+ const topBrowser = webProgress.browsingContext.topFrameElement;
+
+ try {
+ const actor =
+ topBrowser.browsingContext.currentWindowGlobal.getActor(
+ "PageExtractor"
+ );
+
+ actor.waitForPageReady().then(() => {
+ lazy.console.log("Headless PageExtractor is ready", url);
+ actorResolver.resolve(actor);
+ });
+ } catch (error) {
+ // TODO (Bug 2001385) - It would be nice to catch if this is the
+ // `about:neterror` page or other similar errors. This will also fail if you
+ // try to access something like `about:reader` with the same error.
+ actorResolver.reject(
+ new Error(
+ "PageExtractor could not run on that page or the page could not be found."
+ )
+ );
+ }
+ },
+ };
+
+ browser.addProgressListener(onLocationChange, locationChangeFlags);
+
+ lazy.console.log("Loading a headless PageExtractor", url);
+
+ browser.loadURI(url.URI, {
+ triggeringPrincipal:
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ });
+
+ return callback(await actorResolver.promise);
+ },
+ {
+ // Create a custom message manager group for this browser so that the PageExtractor
+ // actor can communicate with it. The actor is registered to use this custom
+ // message manager group.
+ messageManagerGroup: "headless-browsers",
+ }
+ );
}
}
diff --git a/toolkit/modules/HiddenFrame.sys.mjs b/toolkit/modules/HiddenFrame.sys.mjs
index 8a412ffe046..a6ad2841e79 100644
--- a/toolkit/modules/HiddenFrame.sys.mjs
+++ b/toolkit/modules/HiddenFrame.sys.mjs
@@ -176,6 +176,7 @@ export const HiddenBrowserManager = new (class HiddenBrowserManager {
browser.style.height = `${BACKGROUND_HEIGHT}px`;
browser.style.minHeight = `${BACKGROUND_HEIGHT}px`;
browser.setAttribute("maychangeremoteness", "true");
+ browser.setAttribute("nodefaultsrc", "true");
if (messageManagerGroup) {
browser.setAttribute("messagemanagergroup", messageManagerGroup);
}