Medium firefox Logic Error 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactmoderate
DescriptionMemory safety bug fixed in Firefox 152
ComponentToolkit
Bug ClassLogic Error
Tracker1704114
Fix commit238273dd6125 (firefox) +83/-79
CISA KEVNot listed
CreditedNiklas
Disclosed2026-06-16

Background

`PageExtractorParent`
a JSWindowActorParent in the pageextractor component that drives a headless <browser> to extract content from a remote page.
`HiddenBrowserManager`
a toolkit singleton (in HiddenFrame.sys.mjs) that creates and owns the lifetime of off-screen hidden <browser> elements via withHiddenBrowser.
`messagemanagergroup`
a <browser> attribute that binds the browser’s frameloader to a named message-manager group so a JSWindowActor can be scoped to it.
`nodefaultsrc`
a <browser> attribute that suppresses the automatic initial about:blank document 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.

Key insight
The single core mistake was mutating a hidden <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

  1. Trigger extraction Content that can reach the PageExtractor feature causes PageExtractorParent to open a headless browser for an http:/https: host.
  2. Race the default load The freshly created browser begins its default about:blank load before messagemanagergroup and remoteness are applied inside the callback.
  3. Group/frameloader mismatch During that window the frameloader and WindowGlobal exist in a state inconsistent with the intended message-manager group and actor registration.
  4. Operate on stale state onLocationChange proceeds to resolve and use an actor/browsingContext whose underlying frameloader state was reconstructed out from under it, reaching a freed or mismatched object.

Impact Assessment

An attacker gains a memory-safety corruption (moderate severity, per the metadata) in the privileged parent process that hosts 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

FunctionChangeNotes
onLocationChange
toolkit/components/pageextractor/PageExtractorParent.sys.mjs
modified
if
toolkit/components/pageextractor/PageExtractorParent.sys.mjs
modified
if
toolkit/modules/HiddenFrame.sys.mjs
modified

Files Changed

  • toolkit/components/pageextractor/PageExtractorParent.sys.mjs
  • toolkit/modules/HiddenFrame.sys.mjs

Audit Directions

  • Post-creation remoteness/group mutation
    Flag any code that sets messagemanagergroup or maychangeremoteness on a <browser> after it has been inserted and its frameloader may already exist; require these to be fixed at creation.
  • Default-source frameloaders
    Audit hidden/headless <browser> creation for missing nodefaultsrc, since an eager default about:blank load can race intended remoteness/group setup and force fragile navigation guards.
  • Actor lookup on in-flux contexts
    Review getActor/currentWindowGlobal usage inside onLocationChange and similar listeners to ensure the browsingContext frameloader is stable before the actor is resolved and used.
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);
     }
Loading diff…