CVE-2026-117981
Overview
Background
- `escapeHtmlEntities`
- A small helper that replaces
& < > " 'with HTML entities. The patch consolidates it: it is removed fromPlacesUtilsand added toBrowserUtils, and all callers now uselazy.BrowserUtils.escapeHtmlEntities. - Clipboard HTML flavor
- Pasting
text/htmlinto 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 ' versus the consolidated ').
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.
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
- Seed
Attacker gets a crafted URL containing
"/</>in front of the victim (a malicious link, bookmark, or page element the UI will “copy link”). - Copy
Victim invokes a copy-link action that calls
BrowserUtils.copyLinks, which builds thetext/htmlanchor with the raw URL. - Inject
The quote closes the
HREFattribute and the rest of the URL becomes markup inside the clipboard HTML. - Paste Victim pastes into a rich-text/HTML-consuming target (inferred); the injected markup is parsed as HTML.
- 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
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
| Function | Change | Notes |
|---|---|---|
iftoolkit/components/places/BookmarkHTMLUtils.sys.mjs |
modified | |
_writeDateAttributestoolkit/components/places/BookmarkHTMLUtils.sys.mjs |
modified | |
escapeHtmlEntitiestoolkit/components/places/PlacesUtils.sys.mjs |
modified | |
escapeHtmlEntitiestoolkit/modules/BrowserUtils.sys.mjs |
modified | |
copyLinkstoolkit/modules/BrowserUtils.sys.mjs |
modified | |
add_tasktoolkit/modules/tests/browser/browser_BrowserUtils.js |
modified |
Files Changed
toolkit/components/places/BookmarkHTMLUtils.sys.mjstoolkit/components/places/PlacesUtils.sys.mjstoolkit/modules/BrowserUtils.sys.mjstoolkit/modules/tests/browser/browser_BrowserUtils.js
Audit Directions
- Other clipboard HTML buildersSearch
toolkit/,browser/for other code that interpolates URLs/titles intotext/htmlortext/x-moz-urlflavors without escaping attribute context. - Attribute vs text escapingReview places that escape text content but not attribute values (the inverse mistake), especially template literals building
HREF/SRCattributes. - Duplicate escaper divergenceNow that
escapeHtmlEntitiesis consolidated, grep for any remaining ad-hoc HTML-escape helpers that may differ (e.g.'vs', missing"). - Bookmark/HTML export round-tripsVerify exported bookmark HTML and any sync/import paths escape every attacker-controllable field (keyword, postData, charset, tags, favicon data URIs).
Patch
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, "&")
- .replace(/</g, "<")
- .replace(/>/g, ">")
- .replace(/"/g, """)
- .replace(/'/g, "'");
- },
-
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, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+ },
+
/**
* 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, "&")
- .replace(/>/g, ">")
- .replace(/</g, "<")
- .replace(/"/g, """)
- .replace(/'/g, "'");
-
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&b="2"&c='3'&d=<4>">` +
+ `Q&A <"Quotes" and 'apostrophes'></A><BR>\n` +
+ `<A HREF="https://example.org/"></A>`,
+ "Should contain the expected HTML anchors"
+ );
+});
Regression Test / PoC
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&b="2"&c='3'&d=<4>">` +
+ `Q&A <"Quotes" and 'apostrophes'></A><BR>\n` +
+ `<A HREF="https://example.org/"></A>`,
+ "Should contain the expected HTML anchors"
+ );
+});