CVE-2024-10460
Overview
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.jsasthis._principal. - `NullPrincipal`
- An opaque, origin-less principal assigned to contexts such as
data:URIs and sandboxed frames; its real origin, if any, lives in itsprecursorPrincipal. - `BrowsingContext`
- The container representing a frame or top-level window, exposing
top.embedderElementandcurrentWindowGlobal.documentURIused 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.
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
- 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. - Embed an external-protocol trigger
Within that context, script or markup initiates navigation to an external protocol scheme, causing
permissionDialog.jsto open the handler prompt. - Match the top-level principal check
Because the triggering
this._principalcompares equal to the top embedder’scontentPrincipal,triggeringPrincipalIsTop()returns true, so the dialog suppresses the origin display. - 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. - 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
data:.Changed Functions
| Function | Change | Notes |
|---|---|---|
triggeringPrincipalIsToptoolkit/mozapps/handling/content/permissionDialog.js |
modified | |
shouldShowPrincipaltoolkit/mozapps/handling/content/permissionDialog.js |
modified | |
iftoolkit/mozapps/handling/content/permissionDialog.js |
modified | |
initL10ntoolkit/mozapps/handling/content/permissionDialog.js |
modified |
Files Changed
toolkit/mozapps/handling/content/permissionDialog.js
Audit Directions
- Principal equality as address-bar trustAudit 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 handlingReview prompts and indicators that read
exposablePrePathor origin from potentiallyNullPrincipalcontexts (data:, sandboxed frames) to ensure they resolve and display theprecursorPrincipalrather than an empty or misleading value. - Non-readable top-level schemesLook for security prompts that assume the top-level document scheme is
http/https/fileand may misbehave when it isdata:,blob:,about:, or other schemes the address bar does not render as a normal origin.
Patch
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, {