Moderate firefox Input Validation 🔧 Commit mapped

Overview

Moderate
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactmoderate
DescriptionPrivilege escalation in the Toolkit component
ComponentToolkit
Bug ClassInput Validation
Tracker2078167
Fix commit5e9033522fec (firefox) +74/-37
CISA KEVNot listed
CreditedMozilla
Disclosed2026-10-27

Background

`escapeHtmlEntities`
A small helper that replaces & < > " ' with HTML entities. The patch consolidates it: it is removed from PlacesUtils and added to BrowserUtils, and all callers now use lazy.BrowserUtils.escapeHtmlEntities.
Clipboard HTML flavor
Pasting text/html into a rich-text or privileged target renders it as markup, so an unescaped attribute/text value becomes active HTML.

Root Cause Analysis

The security-relevant defect is in BrowserUtils.copyLinks. The old code defined a local htmlEscape and applied it only to the title, while interpolating the URL raw into the attribute: `<A HREF="${url}">${htmlEscape(title)}</A>`. A URL containing a double quote (or </>/&) therefore escaped the HREF attribute context and injected arbitrary HTML into the clipboard’s text/html flavor. The patch escapes both url and title with the shared this.escapeHtmlEntities(...), and the added test asserts that a URL like ?a=1&b="2"&c='3'&d=<4> is fully entity-encoded. The parallel changes in BookmarkHTMLUtils / PlacesUtils route every exported field through the same hardened helper (and the old htmlEscape encoded ' as &apos; versus the consolidated &#39;).

The advisory labels this “Privilege escalation in the Toolkit component,” but the diff substantiates an HTML-injection / improper-output-encoding bug: an attacker-controlled URL (e.g. a bookmarked or page-supplied link) copied to the clipboard is emitted as unescaped HTML. The “privilege escalation” framing is the inferred consequence of that injected markup being rendered when pasted into a more-privileged or rich context; the code itself only shows the missing escaping. Trusting the diff, the root cause is failure to entity-encode the URL before embedding it in an HTML attribute.

Key insight
copyLinks escaped the anchor text but not the HREF URL, so a quote in an attacker-influenced URL broke out of the attribute and injected HTML into the clipboard’s text/html flavor.

Attack Path

  1. Seed Attacker gets a crafted URL containing "/</> in front of the victim (a malicious link, bookmark, or page element the UI will “copy link”).
  2. Copy Victim invokes a copy-link action that calls BrowserUtils.copyLinks, which builds the text/html anchor with the raw URL.
  3. Inject The quote closes the HREF attribute and the rest of the URL becomes markup inside the clipboard HTML.
  4. Paste Victim pastes into a rich-text/HTML-consuming target (inferred); the injected markup is parsed as HTML.
  5. Primitive HTML/attribute injection into pasted content; escalation to script execution in a privileged UI context is the advisory’s claim and is (inferred), not shown by the diff.

Impact Assessment

This is parent/chrome-process JS (toolkit module). No memory-safety sink is present; the primitive is HTML injection into the clipboard text/html flavor via an unescaped URL. Realizing the advisory’s “privilege escalation” requires the injected HTML to be rendered in a context that treats clipboard HTML as trusted/privileged markup — that step is not demonstrated by the diff and is inferred. Preconditions: attacker-influenced URL plus a victim copy-and-paste into a susceptible target. The advisory rates it moderate.

Changed Functions

FunctionChangeNotes
if
toolkit/components/places/BookmarkHTMLUtils.sys.mjs
modified
_writeDateAttributes
toolkit/components/places/BookmarkHTMLUtils.sys.mjs
modified
escapeHtmlEntities
toolkit/components/places/PlacesUtils.sys.mjs
modified
escapeHtmlEntities
toolkit/modules/BrowserUtils.sys.mjs
modified
copyLinks
toolkit/modules/BrowserUtils.sys.mjs
modified
add_task
toolkit/modules/tests/browser/browser_BrowserUtils.js
modified

Files Changed

  • toolkit/components/places/BookmarkHTMLUtils.sys.mjs
  • toolkit/components/places/PlacesUtils.sys.mjs
  • toolkit/modules/BrowserUtils.sys.mjs
  • toolkit/modules/tests/browser/browser_BrowserUtils.js

Audit Directions

  • Other clipboard HTML builders
    Search toolkit/, browser/ for other code that interpolates URLs/titles into text/html or text/x-moz-url flavors without escaping attribute context.
  • Attribute vs text escaping
    Review places that escape text content but not attribute values (the inverse mistake), especially template literals building HREF/SRC attributes.
  • Duplicate escaper divergence
    Now that escapeHtmlEntities is consolidated, grep for any remaining ad-hoc HTML-escape helpers that may differ (e.g. &apos; vs &#39;, missing ").
  • Bookmark/HTML export round-trips
    Verify exported bookmark HTML and any sync/import paths escape every attacker-controllable field (keyword, postData, charset, tags, favicon data URIs).
diff --git a/toolkit/components/places/BookmarkHTMLUtils.sys.mjs b/toolkit/components/places/BookmarkHTMLUtils.sys.mjs
index 0bc0bdee44d..d4de0a7c639 100644
--- a/toolkit/components/places/BookmarkHTMLUtils.sys.mjs
+++ b/toolkit/components/places/BookmarkHTMLUtils.sys.mjs
@@ -62,6 +62,7 @@ import { PlacesUtils } from "resource://gre/modules/PlacesUtils.sys.mjs";
 const lazy = {};
 
 ChromeUtils.defineESModuleGetters(lazy, {
+  BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs",
   PlacesBackups: "resource://gre/modules/PlacesBackups.sys.mjs",
 });
 
@@ -921,7 +922,9 @@ BookmarkExporter.prototype = {
   async _writeContainer(aItem, aIndent = "") {
     if (aItem == this._root) {
       this._writeLine(
-        "<H1>" + PlacesUtils.escapeHtmlEntities(this._root.title) + "</H1>"
+        "<H1>" +
+          lazy.BrowserUtils.escapeHtmlEntities(this._root.title) +
+          "</H1>"
       );
       this._writeLine("");
     } else {
@@ -934,7 +937,7 @@ BookmarkExporter.prototype = {
         this._writeAttribute("UNFILED_BOOKMARKS_FOLDER", "true");
       }
       this._writeLine(
-        ">" + PlacesUtils.escapeHtmlEntities(aItem.title) + "</H3>"
+        ">" + lazy.BrowserUtils.escapeHtmlEntities(aItem.title) + "</H3>"
       );
     }
 
@@ -967,7 +970,10 @@ BookmarkExporter.prototype = {
     this._write(aIndent + "<HR");
     // We keep exporting separator titles, but don't support them anymore.
     if (aItem.title) {
-      this._writeAttribute("NAME", PlacesUtils.escapeHtmlEntities(aItem.title));
+      this._writeAttribute(
+        "NAME",
+        lazy.BrowserUtils.escapeHtmlEntities(aItem.title)
+      );
     }
     this._write(">");
   },
@@ -981,19 +987,22 @@ BookmarkExporter.prototype = {
     }
 
     this._write(aIndent + "<DT><A");
-    this._writeAttribute("HREF", PlacesUtils.escapeHtmlEntities(aItem.uri));
+    this._writeAttribute(
+      "HREF",
+      lazy.BrowserUtils.escapeHtmlEntities(aItem.uri)
+    );
     this._writeDateAttributes(aItem);
     await this._writeFaviconAttribute(aItem);
 
     if (aItem.keyword) {
       this._writeAttribute(
         "SHORTCUTURL",
-        PlacesUtils.escapeHtmlEntities(aItem.keyword)
+        lazy.BrowserUtils.escapeHtmlEntities(aItem.keyword)
       );
       if (aItem.postData) {
         this._writeAttribute(
           "POST_DATA",
-          PlacesUtils.escapeHtmlEntities(aItem.postData)
+          lazy.BrowserUtils.escapeHtmlEntities(aItem.postData)
         );
       }
     }
@@ -1001,13 +1010,18 @@ BookmarkExporter.prototype = {
     if (aItem.charset) {
       this._writeAttribute(
         "LAST_CHARSET",
-        PlacesUtils.escapeHtmlEntities(aItem.charset)
+        lazy.BrowserUtils.escapeHtmlEntities(aItem.charset)
       );
     }
     if (aItem.tags) {
-      this._writeAttribute("TAGS", PlacesUtils.escapeHtmlEntities(aItem.tags));
+      this._writeAttribute(
+        "TAGS",
+        lazy.BrowserUtils.escapeHtmlEntities(aItem.tags)
+      );
     }
-    this._writeLine(">" + PlacesUtils.escapeHtmlEntities(aItem.title) + "</A>");
+    this._writeLine(
+      ">" + lazy.BrowserUtils.escapeHtmlEntities(aItem.title) + "</A>"
+    );
   },
 
   _writeDateAttributes(aItem) {
@@ -1037,13 +1051,13 @@ BookmarkExporter.prototype = {
 
       this._writeAttribute(
         "ICON_URI",
-        PlacesUtils.escapeHtmlEntities(favicon.uri.spec)
+        lazy.BrowserUtils.escapeHtmlEntities(favicon.uri.spec)
       );
 
       if (favicon?.rawData.length && !favicon.uri.schemeIs("chrome")) {
         this._writeAttribute(
           "ICON",
-          PlacesUtils.escapeHtmlEntities(favicon.dataURI.spec)
+          lazy.BrowserUtils.escapeHtmlEntities(favicon.dataURI.spec)
         );
       }
     } catch (ex) {
diff --git a/toolkit/components/places/PlacesUtils.sys.mjs b/toolkit/components/places/PlacesUtils.sys.mjs
index 75f7e0536db..8e7bd7ea6e0 100644
--- a/toolkit/components/places/PlacesUtils.sys.mjs
+++ b/toolkit/components/places/PlacesUtils.sys.mjs
@@ -10,6 +10,7 @@ const lazy = {};
 
 ChromeUtils.defineESModuleGetters(lazy, {
   Bookmarks: "resource://gre/modules/Bookmarks.sys.mjs",
+  BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs",
   History: "resource://gre/modules/History.sys.mjs",
   PlacesSyncUtils: "resource://gre/modules/PlacesSyncUtils.sys.mjs",
   Sqlite: "resource://gre/modules/Sqlite.sys.mjs",
@@ -436,21 +437,6 @@ export var PlacesUtils = {
   // Deprecated: This is only used for supporting import from older datasets.
   MOBILE_ROOT_ANNO: "mobile/bookmarksRoot",
 
-  /**
-   * Escapes a string for safe use in HTML text content or attribute values.
-   *
-   * @param {string} text
-   * @returns {string}
-   */
-  escapeHtmlEntities(text) {
-    return (text || "")
-      .replace(/&/g, "&amp;")
-      .replace(/</g, "&lt;")
-      .replace(/>/g, "&gt;")
-      .replace(/"/g, "&quot;")
-      .replace(/'/g, "&#39;");
-  },
-
   TOPIC_SHUTDOWN: "places-shutdown",
   TOPIC_INIT_COMPLETE: "places-init-complete",
   TOPIC_DATABASE_LOCKED: "places-database-locked",
@@ -1124,7 +1110,7 @@ export var PlacesUtils = {
 
     function gatherDataHtml(node, recursiveOpen = true) {
       // escape out potential HTML in the title
-      let escapedTitle = PlacesUtils.escapeHtmlEntities(node.title);
+      let escapedTitle = lazy.BrowserUtils.escapeHtmlEntities(node.title);
 
       if (PlacesUtils.nodeIsContainer(node)) {
         asContainer(node);
@@ -1152,7 +1138,7 @@ export var PlacesUtils = {
         return childString + "</DL>" + NEWLINE;
       }
       if (PlacesUtils.nodeIsURI(node)) {
-        return `<A HREF="${PlacesUtils.escapeHtmlEntities(node.uri)}">${escapedTitle}</A>${NEWLINE}`;
+        return `<A HREF="${lazy.BrowserUtils.escapeHtmlEntities(node.uri)}">${escapedTitle}</A>${NEWLINE}`;
       }
       if (PlacesUtils.nodeIsSeparator(node)) {
         return "<HR>" + NEWLINE;
diff --git a/toolkit/modules/BrowserUtils.sys.mjs b/toolkit/modules/BrowserUtils.sys.mjs
index a604fe8fdb9..8ba96ab280a 100644
--- a/toolkit/modules/BrowserUtils.sys.mjs
+++ b/toolkit/modules/BrowserUtils.sys.mjs
@@ -253,6 +253,21 @@ export var BrowserUtils = {
     this.copyLinks([{ url, title }]);
   },
 
+  /**
+   * Escapes a string for safe use in HTML text content or attribute values.
+   *
+   * @param {string} text
+   * @returns {string}
+   */
+  escapeHtmlEntities(text) {
+    return (text || "")
+      .replace(/&/g, "&amp;")
+      .replace(/</g, "&lt;")
+      .replace(/>/g, "&gt;")
+      .replace(/"/g, "&quot;")
+      .replace(/'/g, "&#39;");
+  },
+
   /**
    * Copy multiple links to the clipboard. Writes three clipboard flavors:
    *   text/x-moz-url  — "url\ntitle" pairs separated by newlines
@@ -262,19 +277,14 @@ export var BrowserUtils = {
    * @param {Array<{url: string, title: string}>} links
    */
   copyLinks(links) {
-    let htmlEscape = s =>
-      s
-        .replace(/&/g, "&amp;")
-        .replace(/>/g, "&gt;")
-        .replace(/</g, "&lt;")
-        .replace(/"/g, "&quot;")
-        .replace(/'/g, "&apos;");
-
     let mozURLData = links
       .map(({ url, title }) => `${url}\n${title}`)
       .join("\n");
     let htmlData = links
-      .map(({ url, title }) => `<A HREF="${url}">${htmlEscape(title)}</A>`)
+      .map(
+        ({ url, title }) =>
+          `<A HREF="${this.escapeHtmlEntities(url)}">${this.escapeHtmlEntities(title)}</A>`
+      )
       .join("<BR>\n");
     let textData = links.map(({ url }) => url).join("\n");
 
diff --git a/toolkit/modules/tests/browser/browser_BrowserUtils.js b/toolkit/modules/tests/browser/browser_BrowserUtils.js
index 0797572e688..060b148fda2 100644
--- a/toolkit/modules/tests/browser/browser_BrowserUtils.js
+++ b/toolkit/modules/tests/browser/browser_BrowserUtils.js
@@ -76,3 +76,30 @@ add_task(async function test_getSelectionDetails_shadow_selection() {
     });
   });
 });
+
+add_task(async function test_copyLinks_html() {
+  const url = `https://example.com/?a=1&b="2"&c='3'&d=<4>`;
+  const title = `Q&A <"Quotes" and 'apostrophes'>`;
+  BrowserUtils.copyLinks([
+    { url, title },
+    { url: "https://example.org/", title: null },
+  ]);
+
+  let xferable = Cc["@mozilla.org/widget/transferable;1"].createInstance(
+    Ci.nsITransferable
+  );
+  xferable.init(null);
+  xferable.addDataFlavor("text/html");
+  Services.clipboard.getData(xferable, Ci.nsIClipboard.kGlobalClipboard);
+  let data = {};
+  xferable.getTransferData("text/html", data);
+  let html = data.value.QueryInterface(Ci.nsISupportsString).data;
+
+  Assert.stringContains(
+    html,
+    `<A HREF="https://example.com/?a=1&amp;b=&quot;2&quot;&amp;c=&#39;3&#39;&amp;d=&lt;4&gt;">` +
+      `Q&amp;A &lt;&quot;Quotes&quot; and &#39;apostrophes&#39;&gt;</A><BR>\n` +
+      `<A HREF="https://example.org/"></A>`,
+    "Should contain the expected HTML anchors"
+  );
+});
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/toolkit/modules/tests/browser/browser_BrowserUtils.js b/toolkit/modules/tests/browser/browser_BrowserUtils.js
index 0797572e688..060b148fda2 100644
--- a/toolkit/modules/tests/browser/browser_BrowserUtils.js
+++ b/toolkit/modules/tests/browser/browser_BrowserUtils.js
@@ -76,3 +76,30 @@ add_task(async function test_getSelectionDetails_shadow_selection() {
     });
   });
 });
+
+add_task(async function test_copyLinks_html() {
+  const url = `https://example.com/?a=1&b="2"&c='3'&d=<4>`;
+  const title = `Q&A <"Quotes" and 'apostrophes'>`;
+  BrowserUtils.copyLinks([
+    { url, title },
+    { url: "https://example.org/", title: null },
+  ]);
+
+  let xferable = Cc["@mozilla.org/widget/transferable;1"].createInstance(
+    Ci.nsITransferable
+  );
+  xferable.init(null);
+  xferable.addDataFlavor("text/html");
+  Services.clipboard.getData(xferable, Ci.nsIClipboard.kGlobalClipboard);
+  let data = {};
+  xferable.getTransferData("text/html", data);
+  let html = data.value.QueryInterface(Ci.nsISupportsString).data;
+
+  Assert.stringContains(
+    html,
+    `<A HREF="https://example.com/?a=1&amp;b=&quot;2&quot;&amp;c=&#39;3&#39;&amp;d=&lt;4&gt;">` +
+      `Q&amp;A &lt;&quot;Quotes&quot; and &#39;apostrophes&#39;&gt;</A><BR>\n` +
+      `<A HREF="https://example.org/"></A>`,
+    "Should contain the expected HTML anchors"
+  );
+});
Loading diff…