CVE-2026-102329
Overview
Background
- Read Anything / Reading mode
- A Chrome side-panel WebUI (
chrome-untrusted://read-anything-side-panel.top-chrome) that “distills” the content of an arbitrary web page and renders it in the privileged WebUI context. - Readability distillation
- The distiller extracts article HTML from the source page; because it originates from untrusted web content, that HTML must be sanitized before being inserted into the WebUI DOM.
- Trusted Types / identity policy
- A CSP mechanism requiring a
TrustedHTMLobject for sinks likeinnerHTML; a policy whosecreateHTML: (s) => sis an identity passthrough satisfies Trusted Types without performing any sanitization. - `Element.setHTML()`
- The Sanitizer API that parses a string and inserts it while removing unsafe elements/attributes, optionally driven by a
SanitizerConfig.
Root Cause Analysis
In content_controller.ts, the distilled article HTML was inserted into the DOM via contentContainer.innerHTML = this.getTrustedHtml(contentHtml), where getTrustedHtml returned a TrustedHTML produced by the reader-mode-policy Trusted Types policy created in configureTrustedTypes(). That policy’s createHTML: (s: string) => s was a pure identity function, so it merely rubber-stamped the untrusted distilled HTML as trusted and passed it, unsanitized, to the innerHTML sink in the chrome-untrusted:// WebUI. Since the distilled content is derived from an arbitrary attacker-controlled web page, it could carry active markup (e.g. an <iframe srcdoc> containing script, event-handler attributes, or javascript: URLs) that executes when parsed into the WebUI document, yielding cross-site scripting in the WebUI origin.
The fix removes the identity policy path entirely — deleting getTrustedHtml, configureTrustedTypes, and its call in app.ts, and dropping reader-mode-policy from the WebUI’s trusted-types CSP — and replaces the sink with contentContainer.setHTML(contentHtml, READABILITY_SET_HTML_OPTIONS), so the browser’s Sanitizer API parses and sanitizes the distilled HTML (also removing style, meta, and template) before insertion.
The added tests confirm that iframe/script are dropped and javascript: hrefs are neutralized.
createHTML: (s) => s) before assigning it to innerHTML, which satisfied Trusted Types without removing anything; the fix routes the same content through the sanitizing Element.setHTML() API and removes the do-nothing policy from both code and CSP.Attack Path
- Lure the victim to distill a hostile page The attacker gets the user to open Reading mode / Read Anything on an attacker-controlled (or compromised) web page whose article body contains active markup.
- Embed executable markup in the article
The page includes constructs that run on HTML parsing, such as an
<iframe srcdoc="<script>...</script>">, event-handler attributes, orjavascript:URLs (mirroring the test’siframe srcdocandjavascript:void(0)cases). - Distiller passes it through unsanitized
Readability extracts the markup and, pre-patch,
getTrustedHtmlwraps it via the identityreader-mode-policyand assigns it tocontentContainer.innerHTMLin thechrome-untrusted://side panel with no sanitization. - Script executes in the WebUI
The injected markup runs in the Read Anything WebUI origin (XSS), giving script execution in that privileged WebUI context and access to whatever
chrome-untrusted://capabilities it exposes(inferred).
Impact Assessment
chrome-untrusted:// Read Anything WebUI, i.e. cross-site scripting in a WebUI context. The vendor advisory rates this high severity and does not report in-the-wild exploitation; NVD scores it CVSS 6.1. Any escalation from WebUI script execution to broader compromise is (inferred). The precondition is that the victim runs Reading mode with Readability distillation on attacker-influenced content.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/resources/side_panel/read_anything/content/content_controller.ts |
modified |
Files Changed
chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_ui.ccchrome/test/data/webui/side_panel/read_anything/app_content_test.tschrome/browser/resources/side_panel/read_anything/app/app.tschrome/test/data/webui/side_panel/read_anything/content_controller_test.tschrome/browser/resources/side_panel/read_anything/app/read_anything.d.tschrome/browser/resources/side_panel/read_anything/content/content_controller.ts
Audit Directions
- Identity Trusted Types policiesSearch WebUI for
createPolicy(...)whosecreateHTMLreturns its input unchanged ((s) => s); these defeat the purpose of Trusted Types and mark an unsanitizedinnerHTML/outerHTMLsink. - Untrusted content into WebUI sinksAudit
chrome:///chrome-untrusted://code paths that assign web-page-derived or IPC-derived HTML toinnerHTML/insertAdjacentHTML, and preferElement.setHTML()with an explicitSanitizerConfig. - Distiller/reader output handlingReview other consumers of Readability/distilled or otherwise page-sourced HTML to confirm they sanitize before rendering rather than relying on a passthrough Trusted Types wrapper.
Patch
From 7f10942003c3809a7d00c8012d6cc024e93dd42c Mon Sep 17 00:00:00 2001 From: Lauren Winston <[email protected]> Date: Mon, 21 Sep 2026 20:43:58 -0700 Subject: [PATCH] Use Element.setHTML() instead of configureTrustedTypes. Updates Readability distillations to use setHtml() instead of setting the HTML directly via innerHTML. See crbug.com/563297615 for more details. Fixed: 563297615 Change-Id: I16e66704f044ca50cb03dfe9a6e1d727a0a8853f Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8437115 Commit-Queue: Lauren Winston <[email protected]> Reviewed-by: Gustavo Martin <[email protected]> Reviewed-by: Kristi Saney <[email protected]> Cr-Commit-Position: refs/heads/main@{#1702352} --- diff --git a/chrome/browser/resources/side_panel/read_anything/app/app.ts b/chrome/browser/resources/side_panel/read_anything/app/app.ts index 5b36dd3..483df88 100644 --- a/chrome/browser/resources/side_panel/read_anything/app/app.ts +++ b/chrome/browser/resources/side_panel/read_anything/app/app.ts @@ -181,9 +181,6 @@ this.nodeStore_.clear(); ColorChangeUpdater.forDocument().start(); this.contentState_ = this.contentController_.getState(); - if (this.contentBrowserProxy_.isReadabilityEnabled()) { - this.contentController_.configureTrustedTypes(); - } this.isReadAnythingImprovedUiEnabled_ = this.visualBrowserProxy_.isReadAnythingImprovedUiEnabled(); } diff --git a/chrome/browser/resources/side_panel/read_anything/app/read_anything.d.ts b/chrome/browser/resources/side_panel/read_anything/app/read_anything.d.ts index 1c2bc5ee..1ab4b0c8 100644 --- a/chrome/browser/resources/side_panel/read_anything/app/read_anything.d.ts +++ b/chrome/browser/resources/side_panel/read_anything/app/read_anything.d.ts @@ -4,9 +4,16 @@ /** @fileoverview Definitions for chrome.readingMode API */ +// Sanitizer API definitions for Element.prototype.setHTML. +// Note: SanitizerConfig and related types are already declared in lib.dom.d.ts. +interface SetHtmlOptions { + sanitizer?: SanitizerConfig; +} + // Add non-standard function to element for TS to compile correctly. interface Element { scrollIntoViewIfNeeded: () => void; + setHTML(html: string, options?: SetHtmlOptions): void; } interface AxTreeAnchorMetadata { diff --git a/chrome/browser/resources/side_panel/read_anything/content/content_controller.ts b/chrome/browser/resources/side_panel/read_anything/content/content_controller.ts index 855056a..8df0de8 100644 --- a/chrome/browser/resources/side_panel/read_anything/content/content_controller.ts +++ b/chrome/browser/resources/side_panel/read_anything/content/content_controller.ts @@ -23,6 +23,12 @@ import {removeExtraneousElementsFrom} from './readability_content_processing.js'; import {ReadabilityImageClassifier} from './readability_image_classifier.js'; +const READABILITY_SET_HTML_OPTIONS: SetHtmlOptions = { + sanitizer: { + removeElements: ['style', 'meta', 'template'], + }, +}; + const DATA_PREFIX = 'data-'; const LINK_DATA_ATTR = 'link'; const LINKS_OFF_TAG = 'span'; @@ -156,13 +162,6 @@ private currentState_: ContentState = CONTENT_STATES[ContentType.NO_CONTENT]; private previousRootId_?: number; - // The Trusted Types policy is registered globally on the window object. - // The browser only allows a policy name to be registered once per page load. - // Making this static ensures that multiple instances of ContentController - // (which occur frequently during WebUI test runs) share the same policy - // reference and do not trigger a "Policy already exists" TypeError. - private static trustedUpdatePolicy: TrustedTypePolicy|undefined; - // Holds the text nodes extracted from the current rendered distillation. // This array ensures that we can link a DOM node back to its AXTree // mapping for text selection via its index in this array. @@ -286,8 +285,7 @@ } const contentContainer = document.createElement('div'); - contentContainer.innerHTML = this.getTrustedHtml(contentHtml); - + contentContainer.setHTML(contentHtml, READABILITY_SET_HTML_OPTIONS); // Strip single newlines from text nodes to prevent unexpected sentence // breaks, as Readability preserves raw HTML newlines which are visually @@ -1116,25 +1114,6 @@ }); } - private getTrustedHtml(html: string): TrustedHTML { - if (!ContentController.trustedUpdatePolicy || !isDistilledByReadability()) { - return window.trustedTypes!.emptyHTML; - } - return ContentController.trustedUpdatePolicy.createHTML(html); - } - - configureTrustedTypes(): void { - if (!window.trustedTypes || ContentController.trustedUpdatePolicy) { - return; - } - ContentController.trustedUpdatePolicy = - window.trustedTypes.createPolicy('reader-mode-policy', { - createHTML: (s: string) => s, - createScript: () => '', - createScriptURL: () => '', - }); - } - static getInstance(): ContentController { return instance || (instance = new ContentController()); } diff --git a/chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_ui.cc b/chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_ui.cc index 656dc497..9e34285a 100644 --- a/chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_ui.cc +++ b/chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_ui.cc @@ -269,7 +269,7 @@ "chrome-untrusted://resources;"); source->OverrideContentSecurityPolicy( network::mojom::CSPDirectiveName::TrustedTypes, - "trusted-types goog#html reader-mode-policy lit-html-desktop " + "trusted-types goog#html lit-html-desktop " "static-types " "parse-html-subset polymer-html-literal " "polymer-template-event-attribute-policy;"); diff --git a/chrome/test/data/webui/side_panel/read_anything/app_content_test.ts b/chrome/test/data/webui/side_panel/read_anything/app_content_test.ts index 88dba21..e63acfc 100644 --- a/chrome/test/data/webui/side_panel/read_anything/app_content_test.ts +++ b/chrome/test/data/webui/side_panel/read_anything/app_content_test.ts @@ -885,7 +885,6 @@ suite('on image toggle with readability', () => { setup(() => { - contentController.configureTrustedTypes(); contentBrowserProxy.activeDistillationMethod = contentBrowserProxy.distillationTypeReadability; }); @@ -986,7 +985,6 @@ const text = 'the best link ever'; contentBrowserProxy.activeDistillationMethod = contentBrowserProxy.distillationTypeReadability; - contentController.configureTrustedTypes(); contentBrowserProxy.htmlContent = `<a href="${url}">${text}</a>`; app.updateContent(); await microtasksFinished(); diff --git a/chrome/test/data/webui/side_panel/read_anything/content_controller_test.ts b/chrome/test/data/webui/side_panel/read_anything/content_controller_test.ts index ecefc76..560e5d2 100644 --- a/chrome/test/data/webui/side_panel/read_anything/content_controller_test.ts +++ b/chrome/test/data/webui/side_panel/read_anything/content_controller_test.ts @@ -326,7 +326,6 @@ async () => { contentBrowserProxy.activeDistillationMethod = contentBrowserProxy.distillationTypeReadability; - contentController.configureTrustedTypes(); contentBrowserProxy.htmlContent = 'I see my present\npartner\n\nin the imperfect tense'; @@ -346,7 +345,6 @@ async () => { contentBrowserProxy.activeDistillationMethod = contentBrowserProxy.distillationTypeReadability; - contentController.configureTrustedTypes(); contentBrowserProxy.htmlContent = '<pre>I see my present\npartner\n\nin the imperfect tense</pre>'; @@ -554,7 +552,6 @@ const url = 'https://www.relsilicon.com/'; contentBrowserProxy.activeDistillationMethod = contentBrowserProxy.distillationTypeReadability; - contentController.configureTrustedTypes(); const text = 'a link'; contentBrowserProxy.htmlContent = `<a href="${url}">${text}</a>`; @@ -640,7 +637,6 @@ contentBrowserProxy.activeDistillationMethod = contentBrowserProxy.distillationTypeReadability; const buttonText = 'Buttons should be seen and not clicked'; - contentController.configureTrustedTypes(); contentBrowserProxy.htmlContent = `<button>${buttonText}</button>`; const root = contentController.updateContent(); @@ -660,7 +656,6 @@ contentBrowserProxy.activeDistillationMethod = contentBrowserProxy.distillationTypeReadability; const markText = 'When everything is important, nothing is'; - contentController.configureTrustedTypes(); contentBrowserProxy.htmlContent = `<mark>${markText}</mark>`; const root = contentController.updateContent(); @@ -872,7 +867,6 @@ contentBrowserProxy.readabilityEnabled = true; contentBrowserProxy.activeDistillationMethod = contentBrowserProxy.distillationTypeReadability; - contentController.configureTrustedTypes(); contentBrowserProxy.htmlContent = `<a href="${url}">${text}</a>`; visualBrowserProxy.linksEnabled = false; @@ -903,7 +897,6 @@ contentBrowserProxy.readabilityEnabled = true; contentBrowserProxy.activeDistillationMethod = contentBrowserProxy.distillationTypeReadability; - contentController.configureTrustedTypes(); contentBrowserProxy.htmlContent = `<a href="${url}">${text}</a>`; visualBrowserProxy.linksEnabled = true; @@ -1735,4 +1728,49 @@ assertTrue(!!nodeStore.getDomNode(nodeId)); }); + + suite('updateContentForReadability', () => { + setup(() => { + contentBrowserProxy.readabilityEnabled = true; + contentBrowserProxy.activeDistillationMethod = + contentBrowserProxy.distillationTypeReadability; + visualBrowserProxy.linksEnabled = true; + }); + + test( + 'distilled content with embedded media frame is sanitized', + async () => { + contentBrowserProxy.htmlContent = '<p>Normal text</p>' + + '<iframe data-video="//www.youtube.com/embed/test" ' + + 'srcdoc="<script>evil()</script>"></iframe>'; + + const root = contentController.updateContent() as DocumentFragment; + await microtasksFinished(); + + assertTrue(!!root); + assertEquals(null, root.querySelector('iframe')); + assertEquals(null, root.querySelector('script')); + assertStringContains(root.textContent || '', 'Normal text'); + }); + + test('strips style, meta, and template elements', async () => { + contentBrowserProxy.htmlContent = + '<style>body { display: none; }</style>' + + '<meta http-equiv="refresh" content="0;url=https://example.com">' + + '<template><p>Hidden</p></template>' + + '<p>Safe text <a href="javascript:void(0)">Link</a></p>'; + + const root = contentController.updateContent() as DocumentFragment; + await microtasksFinished(); + + assertTrue(!!root); + assertEquals(null, root.querySelector('style')); + assertEquals(null, root.querySelector('meta')); + assertEquals(null, root.querySelector('template')); + const link = root.querySelector('a'); + assertTrue(!!link); + assertEquals('', link.getAttribute('href')); + assertStringContains(root.textContent || '', 'Safe text'); + }); + }); });
Regression Test / PoC
diff --git a/chrome/test/data/webui/side_panel/read_anything/app_content_test.ts b/chrome/test/data/webui/side_panel/read_anything/app_content_test.ts
index 88dba21..e63acfc 100644
--- a/chrome/test/data/webui/side_panel/read_anything/app_content_test.ts
+++ b/chrome/test/data/webui/side_panel/read_anything/app_content_test.ts
@@ -885,7 +885,6 @@
suite('on image toggle with readability', () => {
setup(() => {
- contentController.configureTrustedTypes();
contentBrowserProxy.activeDistillationMethod =
contentBrowserProxy.distillationTypeReadability;
});
@@ -986,7 +985,6 @@
const text = 'the best link ever';
contentBrowserProxy.activeDistillationMethod =
contentBrowserProxy.distillationTypeReadability;
- contentController.configureTrustedTypes();
contentBrowserProxy.htmlContent = `<a href="${url}">${text}</a>`;
app.updateContent();
await microtasksFinished();
diff --git a/chrome/test/data/webui/side_panel/read_anything/content_controller_test.ts b/chrome/test/data/webui/side_panel/read_anything/content_controller_test.ts
index ecefc76..560e5d2 100644
--- a/chrome/test/data/webui/side_panel/read_anything/content_controller_test.ts
+++ b/chrome/test/data/webui/side_panel/read_anything/content_controller_test.ts
@@ -326,7 +326,6 @@
async () => {
contentBrowserProxy.activeDistillationMethod =
contentBrowserProxy.distillationTypeReadability;
- contentController.configureTrustedTypes();
contentBrowserProxy.htmlContent =
'I see my present\npartner\n\nin the imperfect tense';
@@ -346,7 +345,6 @@
async () => {
contentBrowserProxy.activeDistillationMethod =
contentBrowserProxy.distillationTypeReadability;
- contentController.configureTrustedTypes();
contentBrowserProxy.htmlContent =
'<pre>I see my present\npartner\n\nin the imperfect tense</pre>';
@@ -554,7 +552,6 @@
const url = 'https://www.relsilicon.com/';
contentBrowserProxy.activeDistillationMethod =
contentBrowserProxy.distillationTypeReadability;
- contentController.configureTrustedTypes();
const text = 'a link';
contentBrowserProxy.htmlContent = `<a href="${url}">${text}</a>`;
@@ -640,7 +637,6 @@
contentBrowserProxy.activeDistillationMethod =
contentBrowserProxy.distillationTypeReadability;
const buttonText = 'Buttons should be seen and not clicked';
- contentController.configureTrustedTypes();
contentBrowserProxy.htmlContent = `<button>${buttonText}</button>`;
const root = contentController.updateContent();
@@ -660,7 +656,6 @@
contentBrowserProxy.activeDistillationMethod =
contentBrowserProxy.distillationTypeReadability;
const markText = 'When everything is important, nothing is';
- contentController.configureTrustedTypes();
contentBrowserProxy.htmlContent = `<mark>${markText}</mark>`;
const root = contentController.updateContent();
@@ -872,7 +867,6 @@
contentBrowserProxy.readabilityEnabled = true;
contentBrowserProxy.activeDistillationMethod =
contentBrowserProxy.distillationTypeReadability;
- contentController.configureTrustedTypes();
contentBrowserProxy.htmlContent = `<a href="${url}">${text}</a>`;
visualBrowserProxy.linksEnabled = false;
@@ -903,7 +897,6 @@
contentBrowserProxy.readabilityEnabled = true;
contentBrowserProxy.activeDistillationMethod =
contentBrowserProxy.distillationTypeReadability;
- contentController.configureTrustedTypes();
contentBrowserProxy.htmlContent = `<a href="${url}">${text}</a>`;
visualBrowserProxy.linksEnabled = true;
@@ -1735,4 +1728,49 @@
assertTrue(!!nodeStore.getDomNode(nodeId));
});
+
+ suite('updateContentForReadability', () => {
+ setup(() => {
+ contentBrowserProxy.readabilityEnabled = true;
+ contentBrowserProxy.activeDistillationMethod =
+ contentBrowserProxy.distillationTypeReadability;
+ visualBrowserProxy.linksEnabled = true;
+ });
+
+ test(
+ 'distilled content with embedded media frame is sanitized',
+ async () => {
+ contentBrowserProxy.htmlContent = '<p>Normal text</p>' +
+ '<iframe data-video="//www.youtube.com/embed/test" ' +
+ 'srcdoc="<script>evil()</script>"></iframe>';
+
+ const root = contentController.updateContent() as DocumentFragment;
+ await microtasksFinished();
+
+ assertTrue(!!root);
+ assertEquals(null, root.querySelector('iframe'));
+ assertEquals(null, root.querySelector('script'));
+ assertStringContains(root.textContent || '', 'Normal text');
+ });
+
+ test('strips style, meta, and template elements', async () => {
+ contentBrowserProxy.htmlContent =
+ '<style>body { display: none; }</style>' +
+ '<meta http-equiv="refresh" content="0;url=https://example.com">' +
+ '<template><p>Hidden</p></template>' +
+ '<p>Safe text <a href="javascript:void(0)">Link</a></p>';
+
+ const root = contentController.updateContent() as DocumentFragment;
+ await microtasksFinished();
+
+ assertTrue(!!root);
+ assertEquals(null, root.querySelector('style'));
+ assertEquals(null, root.querySelector('meta'));
+ assertEquals(null, root.querySelector('template'));
+ const link = root.querySelector('a');
+ assertTrue(!!link);
+ assertEquals('', link.getAttribute('href'));
+ assertStringContains(root.textContent || '', 'Safe text');
+ });
+ });
});