Medium CVSS 5.3 firefox Logic Error 🔧 Commit mapped

Overview

Medium
Severity
5.3
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactmoderate
DescriptionThe origin of an external protocol handler prompt could have been obscured using a data: URL within an <code>iframe</code>.
ComponentToolkit
Bug ClassLogic Error
Tracker1912537
Fix commit66466959fac9 (firefox) +37/-23
CWECWE-346 (Origin validation error)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedShaheen Fazim
Disclosed2024-10-29

Background

External protocol handler
A browser mechanism that hands off non-web URI schemes (e.g. mailto:, tel:, custom app schemes) to an external application after prompting the user for permission.
Triggering principal
The security principal (origin) of the document that initiated the navigation to the external protocol, tracked in permissionDialog.js as this._principal.
`NullPrincipal`
An opaque, origin-less principal assigned to contexts such as data: URIs and sandboxed frames; its real origin, if any, lives in its precursorPrincipal.
`BrowsingContext`
The container representing a frame or top-level window, exposing top.embedderElement and currentWindowGlobal.documentURI used to determine what origin is actually shown in the address bar.

Root Cause Analysis

The permission prompt decided whether to display the requesting site’s address using triggeringPrincipalIsTop(), which suppressed the origin only when the triggering this._principal exactly equaled the top-level embedder’s contentPrincipal. This conflated “the request came from the top frame” with “the origin shown in the address bar truthfully represents the requester,” an invariant that breaks when a top-level document has a non-address-bar-readable scheme such as data:. An attacker could load a data: URL as the top-level document (or embed content so the compared principals matched) so that the code took the “top level, don’t show address” path, causing the prompt to omit or misattribute the origin while the address bar showed something unrelated or empty.

The fix replaces the check with shouldShowPrincipal(), which additionally requires that the actually-shown scheme (this._browsingContext.currentWindowGlobal.documentURI.scheme) be one of http, https, or file before trusting the address bar to convey the origin, and otherwise forces the prompt to display the derived origin via userReadablePrincipal. This ensures the prompt affirmatively shows a user-readable origin whenever the address bar cannot be relied upon, closing the spoofing gap.

Key insight
The single mistake was assuming that a request originating from the top-level frame means the address bar already shows the user the true origin, when a data: (or otherwise non-readable) top-level document breaks that assumption; the fix adds an explicit scheme allowlist check (http/https/file) so the dialog only hides the origin when the address bar genuinely displays it.

Attack Path

  1. Craft a deceptive top document The attacker serves a page whose top-level (or embedding) context is a data: URL, whose address the browser does not render as a normal readable origin.
  2. Embed an external-protocol trigger Within that context, script or markup initiates navigation to an external protocol scheme, causing permissionDialog.js to open the handler prompt.
  3. Match the top-level principal check Because the triggering this._principal compares equal to the top embedder’s contentPrincipal, triggeringPrincipalIsTop() returns true, so the dialog suppresses the origin display.
  4. Present an origin-less prompt The user sees a permission prompt to launch an external application with no trustworthy origin shown, while the obscured data: context hides where the request truly came from.
  5. Induce the user to accept Believing the prompt corresponds to a trusted or ambiguous source, the user approves launching the external protocol handler.

Impact Assessment

An attacker gains the ability to spoof or obscure the origin shown in an external-protocol permission prompt in the browser’s chrome/UI context, undermining the user’s ability to judge which site is requesting to launch an external application. This is a moderate-severity origin-confusion/spoofing issue requiring the victim to visit attacker-controlled content and interact with the resulting prompt, with the precondition that the requesting context uses a non-address-bar-readable scheme such as data:.

Changed Functions

FunctionChangeNotes
triggeringPrincipalIsTop
toolkit/mozapps/handling/content/permissionDialog.js
modified
shouldShowPrincipal
toolkit/mozapps/handling/content/permissionDialog.js
modified
if
toolkit/mozapps/handling/content/permissionDialog.js
modified
initL10n
toolkit/mozapps/handling/content/permissionDialog.js
modified

Files Changed

  • toolkit/mozapps/handling/content/permissionDialog.js

Audit Directions

  • Principal equality as address-bar trust
    Audit any UI code that decides to hide or show an origin based solely on comparing a triggering principal to a top-level principal, without checking the scheme actually presented to the user.
  • NullPrincipal / precursor handling
    Review prompts and indicators that read exposablePrePath or origin from potentially NullPrincipal contexts (data:, sandboxed frames) to ensure they resolve and display the precursorPrincipal rather than an empty or misleading value.
  • Non-readable top-level schemes
    Look for security prompts that assume the top-level document scheme is http/https/file and may misbehave when it is data:, blob:, about:, or other schemes the address bar does not render as a normal origin.
diff --git a/toolkit/mozapps/handling/content/permissionDialog.js b/toolkit/mozapps/handling/content/permissionDialog.js
index de9df6c3acf..8a60e5ac137 100644
--- a/toolkit/mozapps/handling/content/permissionDialog.js
+++ b/toolkit/mozapps/handling/content/permissionDialog.js
@@ -62,22 +62,27 @@ let dialog = {
   },
 
   /**
-   * Checks whether the principal that triggered this dialog is top level
-   * (not embedded in a frame).
-   * @returns {boolean} - true if principal is top level, false otherwise.
+   * We only show the website address if the origin is not user-readable
+   * in the address bar.
+   * @returns {boolean} - true if principal is top level and user-readable,
+   *                      false otherwise.
    * If the triggering principal is null this method always returns false.
    */
-  triggeringPrincipalIsTop() {
+  shouldShowPrincipal() {
     if (!this._principal) {
       return false;
     }
 
     let topContentPrincipal =
       this._browsingContext?.top.embedderElement?.contentPrincipal;
-    if (!topContentPrincipal) {
-      return false;
-    }
-    return this._principal.equals(topContentPrincipal);
+
+    let shownScheme =
+      this._browsingContext.currentWindowGlobal.documentURI.scheme;
+    return (
+      !topContentPrincipal ||
+      !topContentPrincipal.equals(this._principal) ||
+      !["http", "https", "file"].includes(shownScheme)
+    );
   },
 
   /**
@@ -92,7 +97,7 @@ let dialog = {
       return "permission-dialog-description-extension";
     }
 
-    if (this._principal?.schemeIs("file")) {
+    if (this._principal?.schemeIs("file") && !this.shouldShowPrincipal()) {
       if (this._preferredHandlerName) {
         return "permission-dialog-description-file-app";
       }
@@ -107,10 +112,7 @@ let dialog = {
       return "permission-dialog-description-system-noapp";
     }
 
-    // We only show the website address if the request didn't come from the top
-    // level frame. If we can't get a host to display, fall back to the copy
-    // without host.
-    if (!this.triggeringPrincipalIsTop() && this.displayPrePath) {
+    if (this.shouldShowPrincipal() && this.userReadablePrincipal) {
       if (this._preferredHandlerName) {
         return "permission-dialog-description-host-app";
       }
@@ -144,23 +146,35 @@ let dialog = {
   },
 
   /**
-   * Computes the prePath to show in the prompt. It's the prePath of the site
-   * that wants to navigate to the external protocol.
-   * @returns {string|null} - prePath to show, or null if we can't derive an
-   * exposable prePath from the triggering principal.
+   * Computes text to show in the prompt that is a user-understandable
+   * version of what is asking to open the external protocol.
+   * It's usually the prePath of the site that wants to navigate to
+   * the external protocol, though we use the OS syntax for paths if
+   * the request comes from `file://`.
+   * @returns {string|null} - text to show, or null if we can't derive an
+   * readable string from the triggering principal.
    */
-  get displayPrePath() {
+  get userReadablePrincipal() {
     if (!this._principal) {
       return null;
     }
 
     // NullPrincipals don't expose a meaningful prePath. Instead use the
     // precursorPrincipal, which the NullPrincipal was derived from.
-    if (this._principal.isNullPrincipal) {
-      return this._principal.precursorPrincipal?.exposablePrePath;
+    let p = this._principal.isNullPrincipal
+      ? this._principal.precursorPrincipal
+      : this._principal;
+    // Files have `file://` and nothing else as the prepath. Showing the
+    // full path seems more useful for users:
+    if (p?.schemeIs("file")) {
+      try {
+        let { file } = p.URI.QueryInterface(Ci.nsIFileURL);
+        return file.path;
+      } catch (_ex) {
+        return p.spec;
+      }
     }
-
-    return this._principal?.exposablePrePath;
+    return p?.exposablePrePath;
   },
 
   initL10n() {
@@ -184,7 +198,7 @@ let dialog = {
 
     let description = document.getElementById("description");
 
-    let host = this.displayPrePath;
+    let host = this.userReadablePrincipal;
     let scheme = this._handlerInfo.type;
 
     document.l10n.setAttributes(description, this.l10nDescriptionId, {
Loading diff…