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 <lwinston@google.com>
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 <lwinston@google.com>
Reviewed-by: Gustavo Martin <gusmartin@google.com>
Reviewed-by: Kristi Saney <kristislee@google.com>
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');
+ });
+ });
});