Medium CVSS 7.1 firefox Authorization Error 🔧 Commit mapped

Overview

Medium
Severity
7.1
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactmoderate
DescriptionAn attacker with control over a content process could potentially leverage the privileged UITour actor to leak sensitive information or escalate privileges.
ComponentDOM
Bug ClassAuthorization Error
Tracker1915280
Fix commit4ee141adaed3 (firefox) +124/-62
CWECWE-269
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N
CISA KEVNot listed
CreditedAndrew McCreight
Disclosed2025-04-29

Background

UITour actor
A privileged JSWindowActor pair (UITourChild/UITourParent) that lets specific trusted pages (e.g. about:newtab, mozilla.org) drive in-product tour UI. The parent side can perform privileged actions on behalf of page messages.
`ensureTrustedOrigin`
The gate that decides whether a page is allowed to use UITour: it must be top-level, a secure context, and either a chrome document, or hold the uitour permission, or match a configured testing origin.
JSWindowActorChild vs. Parent
The child runs in the (untrusted, potentially compromised) content process; the parent runs in the trusted parent process. Checks performed only in the child are not trustworthy if the content process is compromised.
`UITourUtils`
A new shared module (UITourUtils.sys.mjs) implementing ensureTrustedOrigin(windowGlobal) usable from either side, taking a WindowGlobalParent or WindowGlobalChild and computing the principal/URI/secure-context appropriately.

Root Cause Analysis

The trusted-origin enforcement for UITour lived only in UITourChild (ensureTrustedOrigin() called from handleEvent/sendPageEvent), i.e. in the content process. UITourParent.receiveMessage acted on messages without re-validating the origin in the parent. An attacker who controls a content process can send UITour:* messages directly to the privileged parent actor, bypassing the child-side check entirely, and thereby invoke privileged UITour functionality from an untrusted origin — the CWE-269 privilege/authority problem the advisory describes (leak information or escalate privileges via the privileged UITour actor). The security check was on the wrong side of the process boundary: a privileged actor trusted a decision made in the sandbox it is supposed to be protected from.

The patch moves the check into the trusted parent. UITourParent.receiveMessage now begins with if (!UITourUtils.ensureTrustedOrigin(this.manager)) { return; }, validating the sending window global in the parent process before dispatching any message. The origin logic is extracted into UITourUtils.ensureTrustedOrigin(windowGlobal), which handles both WindowGlobalParent (using browsingContext.secureBrowserUI?.isSecureContext, documentPrincipal, documentURI) and WindowGlobalChild (using contentWindow?.isSecureContext, document.nodePrincipal, documentURIObject), requires top-most (!windowGlobal.browsingContext.parent) and isCurrentGlobal, and checks the uitour permission or a testing origin. To let the parent reach the content window where needed, WindowGlobalChild gains a contentWindow accessor (GetContentWindow, exposed in WindowGlobalActors.webidl). The child continues to call the shared helper (UITourUtils.ensureTrustedOrigin(this.manager)), but authorization is now authoritatively enforced in the parent regardless of the child’s behavior.

Key insight
UITour’s trusted-origin authorization was enforced only in the untrusted content-process child actor, so a compromised content process could message the privileged parent directly and bypass it; the fix re-validates the origin in UITourParent.receiveMessage using a shared UITourUtils.ensureTrustedOrigin.

Attack Path

  1. Compromise content process The attacker gains control of a content process (per the advisory, “an attacker with control over a content process”), bypassing the content-side JS entirely.
  2. Skip the child check Rather than going through UITourChild, the attacker’s process sends UITour:* IPC messages directly to the parent actor.
  3. Parent acts unguarded The old UITourParent.receiveMessage dispatches the message without re-checking the origin, honoring it as if from a trusted page.
  4. Invoke privileged UITour The attacker drives privileged UITour functionality from an untrusted origin.
  5. Primitive Leak of sensitive information or privilege escalation via the privileged UITour actor (the specific escalation strength is inferred; the diff proves only the missing parent-side authorization).

Impact Assessment

The vulnerability sits at the content/parent IPC boundary; the parent (privileged) process is the asset. It requires an already-compromised or controllable content process (PR:L, CVSS 7.1) — not reachable by ordinary web content alone — and yields whatever the privileged UITour actor exposes: information disclosure and potential privilege escalation. The fix is an authorization check, so the residual risk is bounded by UITour’s own capabilities rather than memory safety.

Changed Functions

FunctionChangeNotes
handleEvent
browser/components/uitour/UITourChild.sys.mjs
modified
isTestingOrigin
browser/components/uitour/UITourChild.sys.mjs
modified
if
browser/components/uitour/UITourChild.sys.mjs
modified
isSafeScheme
browser/components/uitour/UITourChild.sys.mjs
modified
ensureTrustedOrigin
browser/components/uitour/UITourChild.sys.mjs
modified
receiveMessage
browser/components/uitour/UITourChild.sys.mjs
modified
switch
browser/components/uitour/UITourChild.sys.mjs
modified
sendPageEvent
browser/components/uitour/UITourChild.sys.mjs
modified
receiveMessage
browser/components/uitour/UITourParent.sys.mjs
modified
switch
browser/components/uitour/UITourParent.sys.mjs
modified
if
browser/components/uitour/UITourParent.sys.mjs
modified
isTestingOrigin
browser/components/uitour/UITourUtils.sys.mjs
modified
if
browser/components/uitour/UITourUtils.sys.mjs
modified
ensureTrustedOrigin
browser/components/uitour/UITourUtils.sys.mjs
modified
nsGlobalWindowInner
dom/ipc/WindowGlobalChild.h
modified
nsDocShell
dom/ipc/WindowGlobalChild.h
modified

Files Changed

  • browser/components/uitour/UITourChild.sys.mjs
  • browser/components/uitour/UITourParent.sys.mjs
  • browser/components/uitour/UITourUtils.sys.mjs
  • browser/components/uitour/moz.build
  • browser/components/uitour/test/browser_UITour_private_browsing.js
  • dom/chrome-webidl/WindowGlobalActors.webidl
  • dom/ipc/WindowGlobalChild.cpp
  • dom/ipc/WindowGlobalChild.h

Audit Directions

  • Checks only in the child actor
    Audit other JSWindowActor pairs (privileged actors) whose authorization/trusted-origin logic runs only in the child; the parent must independently validate the sender.
  • Parent `receiveMessage` trust
    Review *Parent.sys.mjs receiveMessage handlers that act on content-supplied data without verifying this.manager origin/principal.
  • Secure-context/top-most assumptions
    Check other consumers that assume top-level/secure-context state established in content rather than recomputing it from the WindowGlobalParent in the parent.
diff --git a/browser/components/uitour/UITourChild.sys.mjs b/browser/components/uitour/UITourChild.sys.mjs
index 969b6923520..2df3b6e4c51 100644
--- a/browser/components/uitour/UITourChild.sys.mjs
+++ b/browser/components/uitour/UITourChild.sys.mjs
@@ -2,12 +2,11 @@
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
-const PREF_TEST_ORIGINS = "browser.uitour.testingOrigins";
-const UITOUR_PERMISSION = "uitour";
+import { UITourUtils } from "moz-src:///browser/components/uitour/UITourUtils.sys.mjs";
 
 export class UITourChild extends JSWindowActorChild {
   handleEvent(event) {
-    if (!this.ensureTrustedOrigin()) {
+    if (!UITourUtils.ensureTrustedOrigin(this.manager)) {
       return;
     }
 
@@ -18,62 +17,6 @@ export class UITourChild extends JSWindowActorChild {
     });
   }
 
-  isTestingOrigin(aURI) {
-    let testingOrigins = Services.prefs.getStringPref(PREF_TEST_ORIGINS, "");
-    if (!testingOrigins) {
-      return false;
-    }
-
-    // Allow any testing origins (comma-seperated).
-    for (let origin of testingOrigins.split(/\s*,\s*/)) {
-      try {
-        let testingURI = Services.io.newURI(origin);
-        if (aURI.prePath == testingURI.prePath) {
-          return true;
-        }
-      } catch (ex) {
-        console.error(ex);
-      }
-    }
-    return false;
-  }
-
-  // This function is copied from UITour.sys.mjs.
-  isSafeScheme(aURI) {
-    let allowedSchemes = new Set(["https", "about"]);
-    if (!allowedSchemes.has(aURI.scheme)) {
-      return false;
-    }
-
-    return true;
-  }
-
-  ensureTrustedOrigin() {
-    if (this.browsingContext.top != this.browsingContext) {
-      return false;
-    }
-
-    let uri = this.document.documentURIObject;
-
-    if (uri.schemeIs("chrome")) {
-      return true;
-    }
-
-    if (!this.isSafeScheme(uri)) {
-      return false;
-    }
-
-    let permission = Services.perms.testPermissionFromPrincipal(
-      this.document.nodePrincipal,
-      UITOUR_PERMISSION
-    );
-    if (permission == Services.perms.ALLOW_ACTION) {
-      return true;
-    }
-
-    return this.isTestingOrigin(uri);
-  }
-
   receiveMessage(aMessage) {
     switch (aMessage.name) {
       case "UITour:SendPageCallback":
@@ -86,7 +29,7 @@ export class UITourChild extends JSWindowActorChild {
   }
 
   sendPageEvent(type, detail) {
-    if (!this.ensureTrustedOrigin()) {
+    if (!UITourUtils.ensureTrustedOrigin(this.manager)) {
       return;
     }
 
diff --git a/browser/components/uitour/UITourParent.sys.mjs b/browser/components/uitour/UITourParent.sys.mjs
index 56222251074..61506d358db 100644
--- a/browser/components/uitour/UITourParent.sys.mjs
+++ b/browser/components/uitour/UITourParent.sys.mjs
@@ -3,9 +3,13 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 import { UITour } from "moz-src:///browser/components/uitour/UITour.sys.mjs";
+import { UITourUtils } from "moz-src:///browser/components/uitour/UITourUtils.sys.mjs";
 
 export class UITourParent extends JSWindowActorParent {
   receiveMessage(message) {
+    if (!UITourUtils.ensureTrustedOrigin(this.manager)) {
+      return;
+    }
     switch (message.name) {
       case "UITour:onPageEvent":
         if (this.manager.rootFrameLoader) {
diff --git a/browser/components/uitour/UITourUtils.sys.mjs b/browser/components/uitour/UITourUtils.sys.mjs
new file mode 100644
index 00000000000..b390b2b53dc
--- /dev/null
+++ b/browser/components/uitour/UITourUtils.sys.mjs
@@ -0,0 +1,84 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const PREF_TEST_ORIGINS = "browser.uitour.testingOrigins";
+const UITOUR_PERMISSION = "uitour";
+
+export let UITourUtils = {
+  /**
+   * Check if we've got a testing origin.
+   *
+   * @param {nsIURI} uri
+   *    The URI to check
+   * @returns {boolean}
+   *    Whether or not it's a testing origin.
+   */
+  isTestingOrigin(uri) {
+    let testingOrigins = Services.prefs.getStringPref(PREF_TEST_ORIGINS, "");
+    if (!testingOrigins) {
+      return false;
+    }
+
+    // Allow any testing origins (comma-seperated).
+    for (let origin of testingOrigins.split(/\s*,\s*/)) {
+      try {
+        let testingURI = Services.io.newURI(origin);
+        if (uri.prePath == testingURI.prePath) {
+          return true;
+        }
+      } catch (ex) {
+        console.error(ex);
+      }
+    }
+    return false;
+  },
+
+  /**
+   *
+   * @param {WindowGlobalChild|WindowGlobalParent} windowGlobal
+   *   The parent/child representation of a window global to check if we can
+   *   use UITour.
+   * @returns {boolean}
+   *   Whether or not we can use UITour here.
+   */
+  ensureTrustedOrigin(windowGlobal) {
+    // If we're not top-most or no longer current, bail out immediately.
+    if (windowGlobal.browsingContext.parent || !windowGlobal.isCurrentGlobal) {
+      return false;
+    }
+
+    let principal, uri;
+    // We can get either a WindowGlobalParent or WindowGlobalChild, depending on
+    // what process we're called in, and determining the secure context-ness and
+    // principal + URI needs different approaches based on this.
+    if (WindowGlobalParent.isInstance(windowGlobal)) {
+      if (!windowGlobal.browsingContext.secureBrowserUI?.isSecureContext) {
+        return false;
+      }
+      principal = windowGlobal.documentPrincipal;
+      uri = windowGlobal.documentURI;
+    } else {
+      if (!windowGlobal.contentWindow?.isSecureContext) {
+        return false;
+      }
+      let document = windowGlobal.contentWindow.document;
+      principal = document?.nodePrincipal;
+      uri = document?.documentURIObject;
+    }
+
+    if (!principal) {
+      return false;
+    }
+
+    let permission = Services.perms.testPermissionFromPrincipal(
+      principal,
+      UITOUR_PERMISSION
+    );
+    if (permission == Services.perms.ALLOW_ACTION) {
+      return true;
+    }
+
+    return uri && this.isTestingOrigin(uri);
+  },
+};
diff --git a/browser/components/uitour/moz.build b/browser/components/uitour/moz.build
index 3a891ae10a0..e679af7e23e 100644
--- a/browser/components/uitour/moz.build
+++ b/browser/components/uitour/moz.build
@@ -2,7 +2,12 @@
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
-MOZ_SRC_FILES += ["UITour.sys.mjs", "UITourChild.sys.mjs", "UITourParent.sys.mjs"]
+MOZ_SRC_FILES += [
+    "UITour.sys.mjs",
+    "UITourChild.sys.mjs",
+    "UITourParent.sys.mjs",
+    "UITourUtils.sys.mjs",
+]
 
 BROWSER_CHROME_MANIFESTS += [
     "test/browser.toml",
diff --git a/dom/chrome-webidl/WindowGlobalActors.webidl b/dom/chrome-webidl/WindowGlobalActors.webidl
index ead070a2fec..54b47719782 100644
--- a/dom/chrome-webidl/WindowGlobalActors.webidl
+++ b/dom/chrome-webidl/WindowGlobalActors.webidl
@@ -178,6 +178,7 @@ interface WindowGlobalChild {
   readonly attribute boolean isInProcess;
   readonly attribute BrowsingContext browsingContext;
   readonly attribute WindowContext windowContext;
+  readonly attribute WindowProxy? contentWindow;
 
   readonly attribute boolean isCurrentGlobal;
 
diff --git a/dom/ipc/WindowGlobalChild.cpp b/dom/ipc/WindowGlobalChild.cpp
index 356f12605e1..de149d7126c 100644
--- a/dom/ipc/WindowGlobalChild.cpp
+++ b/dom/ipc/WindowGlobalChild.cpp
@@ -258,6 +258,13 @@ dom::BrowsingContext* WindowGlobalChild::BrowsingContext() {
   return mWindowContext->GetBrowsingContext();
 }
 
+Nullable<WindowProxyHolder> WindowGlobalChild::GetContentWindow() {
+  if (IsCurrentGlobal()) {
+    return WindowProxyHolder(BrowsingContext());
+  }
+  return nullptr;
+}
+
 uint64_t WindowGlobalChild::InnerWindowId() {
   return mWindowContext->InnerWindowId();
 }
diff --git a/dom/ipc/WindowGlobalChild.h b/dom/ipc/WindowGlobalChild.h
index b90f692ddcd..b5f207a5b9c 100644
--- a/dom/ipc/WindowGlobalChild.h
+++ b/dom/ipc/WindowGlobalChild.h
@@ -14,6 +14,7 @@
 #include "nsWrapperCache.h"
 #include "mozilla/dom/Document.h"
 #include "mozilla/dom/WindowGlobalActor.h"
+#include "mozilla/dom/WindowProxyHolder.h"
 
 class nsGlobalWindowInner;
 class nsDocShell;
@@ -53,6 +54,8 @@ class WindowGlobalChild final : public WindowGlobalActor,
   dom::WindowContext* WindowContext() const { return mWindowContext; }
   nsGlobalWindowInner* GetWindowGlobal() const { return mWindowGlobal; }
 
+  Nullable<WindowProxyHolder> GetContentWindow();
+
   // Has this actor been shut down
   bool IsClosed() { return !CanSend(); }
   void Destroy();
diff --git a/browser/components/uitour/test/browser_UITour_private_browsing.js b/browser/components/uitour/test/browser_UITour_private_browsing.js
index 0c5a52f6674..d69bb30fbb9 100644
--- a/browser/components/uitour/test/browser_UITour_private_browsing.js
+++ b/browser/components/uitour/test/browser_UITour_private_browsing.js
@@ -16,6 +16,10 @@ add_task(async function test_privatebrowsing_window() {
   const ABOUT_ORIGIN_WITH_UITOUR_DEFAULT = "about:newtab";
   const HTTPS_ORIGIN_WITH_UITOUR_DEFAULT = "https://www.mozilla.org";
 
+  let { UITourUtils } = ChromeUtils.importESModule(
+    "moz-src:///browser/components/uitour/UITourUtils.sys.mjs"
+  );
+
   let win = await BrowserTestUtils.openNewBrowserWindow({ private: true });
   let browser = win.gBrowser.selectedBrowser;
 
@@ -26,10 +30,21 @@ add_task(async function test_privatebrowsing_window() {
     BrowserTestUtils.startLoadingURIString(browser, uri);
     await BrowserTestUtils.browserLoaded(browser);
 
+    Assert.ok(
+      UITourUtils.ensureTrustedOrigin(
+        browser.browsingContext.currentWindowGlobal
+      ),
+      "Page should be considered trusted for UITour in the parent."
+    );
+
     await SpecialPowers.spawn(browser, [], async () => {
       let actor = content.windowGlobalChild.getActor("UITour");
+      // eslint-disable-next-line no-shadow
+      let { UITourUtils } = ChromeUtils.importESModule(
+        "moz-src:///browser/components/uitour/UITourUtils.sys.mjs"
+      );
       Assert.ok(
-        actor.ensureTrustedOrigin(),
+        UITourUtils.ensureTrustedOrigin(actor.manager),
         "Page should be considered trusted for UITour."
       );
     });
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/browser/components/uitour/test/browser_UITour_private_browsing.js b/browser/components/uitour/test/browser_UITour_private_browsing.js
index 0c5a52f6674..d69bb30fbb9 100644
--- a/browser/components/uitour/test/browser_UITour_private_browsing.js
+++ b/browser/components/uitour/test/browser_UITour_private_browsing.js
@@ -16,6 +16,10 @@ add_task(async function test_privatebrowsing_window() {
   const ABOUT_ORIGIN_WITH_UITOUR_DEFAULT = "about:newtab";
   const HTTPS_ORIGIN_WITH_UITOUR_DEFAULT = "https://www.mozilla.org";
 
+  let { UITourUtils } = ChromeUtils.importESModule(
+    "moz-src:///browser/components/uitour/UITourUtils.sys.mjs"
+  );
+
   let win = await BrowserTestUtils.openNewBrowserWindow({ private: true });
   let browser = win.gBrowser.selectedBrowser;
 
@@ -26,10 +30,21 @@ add_task(async function test_privatebrowsing_window() {
     BrowserTestUtils.startLoadingURIString(browser, uri);
     await BrowserTestUtils.browserLoaded(browser);
 
+    Assert.ok(
+      UITourUtils.ensureTrustedOrigin(
+        browser.browsingContext.currentWindowGlobal
+      ),
+      "Page should be considered trusted for UITour in the parent."
+    );
+
     await SpecialPowers.spawn(browser, [], async () => {
       let actor = content.windowGlobalChild.getActor("UITour");
+      // eslint-disable-next-line no-shadow
+      let { UITourUtils } = ChromeUtils.importESModule(
+        "moz-src:///browser/components/uitour/UITourUtils.sys.mjs"
+      );
       Assert.ok(
-        actor.ensureTrustedOrigin(),
+        UITourUtils.ensureTrustedOrigin(actor.manager),
         "Page should be considered trusted for UITour."
       );
     });
Loading diff…