High watch CVSS 6.1 chrome Logic Error 🔧 Commit mapped

Overview

High
Severity
6.1
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactCross-site scripting in WebUI
DescriptionCross-site scripting in WebUI
ComponentWebUI
Bug ClassLogic Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker563297615
Fix commit7f10942003c3 (chromium/src) +60/-41
CWECWE-79 (Cross-site scripting)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N
CISA KEVNot listed
Creditedchipsec
Reported2026-09-18 (per vendor advisory)
Fix landed2026-09-22 (public git)
Disclosed2026-09-29
Patch gap7 days — fix public in source before the advisory

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 TrustedHTML object for sinks like innerHTML; a policy whose createHTML: (s) => s is 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.

Key insight
The single mistake was “sanitizing” untrusted distilled HTML with an identity Trusted Types policy (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

  1. 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.
  2. 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, or javascript: URLs (mirroring the test’s iframe srcdoc and javascript:void(0) cases).
  3. Distiller passes it through unsanitized Readability extracts the markup and, pre-patch, getTrustedHtml wraps it via the identity reader-mode-policy and assigns it to contentContainer.innerHTML in the chrome-untrusted:// side panel with no sanitization.
  4. 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

An attacker whose page content is distilled by Reading mode could inject markup that executes as script in the 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

FunctionChangeNotes
if
chrome/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.cc
  • chrome/test/data/webui/side_panel/read_anything/app_content_test.ts
  • chrome/browser/resources/side_panel/read_anything/app/app.ts
  • chrome/test/data/webui/side_panel/read_anything/content_controller_test.ts
  • chrome/browser/resources/side_panel/read_anything/app/read_anything.d.ts
  • chrome/browser/resources/side_panel/read_anything/content/content_controller.ts

Audit Directions

  • Identity Trusted Types policies
    Search WebUI for createPolicy(...) whose createHTML returns its input unchanged ((s) => s); these defeat the purpose of Trusted Types and mark an unsanitized innerHTML/outerHTML sink.
  • Untrusted content into WebUI sinks
    Audit chrome:///chrome-untrusted:// code paths that assign web-page-derived or IPC-derived HTML to innerHTML/insertAdjacentHTML, and prefer Element.setHTML() with an explicit SanitizerConfig.
  • Distiller/reader output handling
    Review 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.
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');
+    });
+  });
 });
Loading diff…

Regression Test / PoC

shipped with the fix
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');
+    });
+  });
 });
Loading diff…