CVE-2026-78980
Overview
Background
- Reader Mode / DOM Distiller
- the feature that extracts article content from a page and re-renders it in a simplified distilled view.
content_processing.jspost-processes the extracted DOM before it is shown. - `sanitizeLinks`
- the routine in
content_processing.js(its name comes from the regression tests; the enclosing signature is not in the hunk) that walks every<a>under an element, keepshttp/httpslinks (opening them withtarget="_blank"), and unwraps other links such asjavascript:ormailto:, keeping only their text. - SVG `<a xlink:href>`
- SVG anchors can carry their target in the XLink-namespaced attribute
xlink:hrefinstead of a plainhref.element.getAttribute('href')returnsnullfor it, and it can only be read withgetAttributeNS('http://www.w3.org/1999/xlink', 'href'). - `querySelectorAll('a')`
- the type selector matches
<a>elements in any namespace, so SVG anchors are already included inallLinks. Only the attribute read missed them.
Root Cause Analysis
sanitizeLinks read each link’s destination with linkElement.getAttribute('href') and wrapped all sanitization in if (href) { ... }. For an SVG <a> whose target is in xlink:href, that call returns null, so the condition was false and the element was skipped. It was neither unwrapped nor normalized, and it kept its javascript: (or other non-http) destination in the distilled output. This violated the invariant that every anchor reaching the Reader Mode view has an http/https destination or has been unwrapped.
The fix makes the read fall back to the namespaced attribute (getAttribute('href') || getAttributeNS(XLINK_NS, 'href')), so an xlink:href-only anchor enters the existing keepLink logic. A javascript: target is now removed, leaving only its text, and an https target is kept with target="_blank", as both new tests assert.
The patch tightens validation by widening what the sanitizer inspects. The keep/strip decision logic itself is unchanged.
href attribute” to mean “not a link”, but SVG anchors are live links through xlink:href. Reading both attribute forms closes the bypass.Attack Path
- Craft article markup
an attacker-controlled page includes an inline
<svg><a xlink:href="javascript:...">element with text inside, in content that the distiller will extract. - User opens Reader Mode
DOM Distiller extracts the content and runs
sanitizeLinksover it. - Sanitizer skips the anchor
getAttribute('href')returnsnull, theif (href)block does not run, and the SVG anchor and itsjavascript:target pass through unchanged (the pre-patch behavior the new test exercises). - User clicks the link in the distilled view
the surviving
javascript:(or other non-http) link is presented as part of the Reader Mode page. Whether script actually runs depends on the distilled page’s CSP and navigation handling, which the diff does not show(inferred).
Impact Assessment
javascript:, through Reader Mode’s link sanitizer into the distilled view, defeating a defense-in-depth filter. User interaction (opening Reader Mode and clicking) is required. The vendor rates it low as improper input validation, and the diff shows only the sanitizer bypass, so script execution in the Reader Mode context is (inferred) and not demonstrated. The diff contains nothing about in-the-wild exploitation.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcomponents/dom_distiller/core/javascript/content_processing.js |
modified | |
suitecomponents/test/data/dom_distiller/content_processing_tester.js |
modified |
Files Changed
components/dom_distiller/core/javascript/content_processing.jscomponents/test/data/dom_distiller/content_processing_tester.js
Audit Directions
- Namespace and alias blind spots in JS sanitizerslook for other
content_processing.jsor DOM Distiller passes, and WebUI or Reader Mode sanitizers, that readhref/src/actionwithgetAttributeonly. These missxlink:href, MathMLhref, and SVG<use>/<image>attributes. - Dual-attribute precedencethe fix uses
||, so when a non-emptyhrefexists,xlink:hrefis never examined. Check how the keep path rewrites attributes and whether an element with a benignhrefand a hostilexlink:href(or the reverse, depending on the renderer’s precedence) can still navigate to the unchecked value. - Post-sanitization mutationSVG animation elements (
<set>,<animate attributeName="href">) and other link-like elements (<area>,<form action>,formaction) can change or create navigation targets after a link-only pass, so verify the distiller removes or neutralizes them.
Patch
diff --git a/components/dom_distiller/core/javascript/content_processing.js b/components/dom_distiller/core/javascript/content_processing.js
index 2445f16..1b521108 100644
--- a/components/dom_distiller/core/javascript/content_processing.js
+++ b/components/dom_distiller/core/javascript/content_processing.js
@@ -11,7 +11,8 @@
const allLinks = element.querySelectorAll('a');
allLinks.forEach(linkElement => {
- const href = linkElement.getAttribute('href');
+ const href = linkElement.getAttribute('href') ||
+ linkElement.getAttributeNS('http://www.w3.org/1999/xlink', 'href');
if (href) {
let keepLink = false;
diff --git a/components/test/data/dom_distiller/content_processing_tester.js b/components/test/data/dom_distiller/content_processing_tester.js
index 519d5a5..e6ddc5e 100644
--- a/components/test/data/dom_distiller/content_processing_tester.js
+++ b/components/test/data/dom_distiller/content_processing_tester.js
@@ -82,6 +82,37 @@
assert.equal(links.length, 0);
assert.equal(testContainer.innerHTML, 'Mailto Link');
});
+
+ test(
+ 'sanitizeLinks should remove javascript SVG links with xlink:href',
+ async function() {
+ const {assert} = await import('./index.js');
+ testContainer.innerHTML = '<svg><a xlink:href="javascript:void(0)">' +
+ '<text>SVG JS Link</text></a></svg>';
+ sanitizeLinks(testContainer);
+ const links = testContainer.querySelectorAll('a');
+ assert.equal(links.length, 0);
+ assert.equal(
+ testContainer.querySelector('svg').innerHTML,
+ '<text>SVG JS Link</text>');
+ });
+
+ test(
+ 'sanitizeLinks should keep valid http/https SVG links with xlink:href ' +
+ 'and open in new tab',
+ async function() {
+ const {assert} = await import('./index.js');
+ testContainer.innerHTML =
+ '<svg><a xlink:href="https://example.com">' +
+ '<text>SVG HTTPS Link</text></a></svg>';
+ sanitizeLinks(testContainer);
+ const links = testContainer.querySelectorAll('a');
+ assert.equal(links.length, 1);
+ assert.equal(
+ links[0].getAttributeNS('http://www.w3.org/1999/xlink', 'href'),
+ 'https://example.com');
+ assert.equal(links[0].target, '_blank');
+ });
});
suite('ContentProcessing.removeExtraneousElementsFrom', function() {
Regression Test / PoC
diff --git a/components/test/data/dom_distiller/content_processing_tester.js b/components/test/data/dom_distiller/content_processing_tester.js
index 519d5a5..e6ddc5e 100644
--- a/components/test/data/dom_distiller/content_processing_tester.js
+++ b/components/test/data/dom_distiller/content_processing_tester.js
@@ -82,6 +82,37 @@
assert.equal(links.length, 0);
assert.equal(testContainer.innerHTML, 'Mailto Link');
});
+
+ test(
+ 'sanitizeLinks should remove javascript SVG links with xlink:href',
+ async function() {
+ const {assert} = await import('./index.js');
+ testContainer.innerHTML = '<svg><a xlink:href="javascript:void(0)">' +
+ '<text>SVG JS Link</text></a></svg>';
+ sanitizeLinks(testContainer);
+ const links = testContainer.querySelectorAll('a');
+ assert.equal(links.length, 0);
+ assert.equal(
+ testContainer.querySelector('svg').innerHTML,
+ '<text>SVG JS Link</text>');
+ });
+
+ test(
+ 'sanitizeLinks should keep valid http/https SVG links with xlink:href ' +
+ 'and open in new tab',
+ async function() {
+ const {assert} = await import('./index.js');
+ testContainer.innerHTML =
+ '<svg><a xlink:href="https://example.com">' +
+ '<text>SVG HTTPS Link</text></a></svg>';
+ sanitizeLinks(testContainer);
+ const links = testContainer.querySelectorAll('a');
+ assert.equal(links.length, 1);
+ assert.equal(
+ links[0].getAttributeNS('http://www.w3.org/1999/xlink', 'href'),
+ 'https://example.com');
+ assert.equal(links[0].target, '_blank');
+ });
});
suite('ContentProcessing.removeExtraneousElementsFrom', function() {