CVE-2026-11274
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
forcomponents/dom_distiller/core/javascript/content_processing.js |
modified | |
ifcomponents/dom_distiller/core/javascript/content_processing.js |
modified | |
testcomponents/test/data/dom_distiller/viewer_js_tester.js |
modified | |
ifcomponents/test/data/dom_distiller/viewer_js_tester.js |
modified |
Files Changed
components/dom_distiller/core/javascript/content_processing.jscomponents/dom_distiller/core/javascript/dom_distiller_viewer_main.jscomponents/test/data/dom_distiller/viewer_js_tester.js
Patch
From 3390e7a4fdbcc9fafd8ee8ee370a5c1ed2018dbd Mon Sep 17 00:00:00 2001
From: Andrew Paseltiner <apaseltiner@chromium.org>
Date: Wed, 29 Apr 2026 13:30:00 -0700
Subject: [PATCH] [DOM Distiller] Fix unhandled exception in post-processing
An unhandled exception in fillYouTubePlaceholders could halt the
post-processing sequence, bypassing the security-critical sanitizeLinks
call.
This CL:
1. Wraps each post-processing step in a try-catch block in
postProcessElement to ensure subsequent steps always run.
2. Fixes fillYouTubePlaceholders to attach the new iframe to the DOM
before further processing, ensuring it has a parentElement.
3. Switches fillYouTubePlaceholders to use querySelectorAll with
specific attribute selectors (data-type, data-id) and values() for
stable, forward iteration.
Fixed: 501760514
Change-Id: I2c43b1ec06787ea641e8fd7123b7629d07d2d456
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7803098
Commit-Queue: Andrew Paseltiner <apaseltiner@chromium.org>
Reviewed-by: Brandon Wylie <wylieb@google.com>
Cr-Commit-Position: refs/heads/main@{#1622685}
---
diff --git a/components/dom_distiller/core/javascript/content_processing.js b/components/dom_distiller/core/javascript/content_processing.js
index 928ba0d1..2445f16 100644
--- a/components/dom_distiller/core/javascript/content_processing.js
+++ b/components/dom_distiller/core/javascript/content_processing.js
@@ -86,20 +86,24 @@
* @param {HTMLElement} element The element to search for placeholders in.
*/
function fillYouTubePlaceholders(element) {
- const placeholders = element.getElementsByClassName('embed-placeholder');
- for (let i = 0; i < placeholders.length; i++) {
- if (!placeholders[i].hasAttribute('data-type') ||
- placeholders[i].getAttribute('data-type') !== 'youtube' ||
- !placeholders[i].hasAttribute('data-id')) {
- continue;
- }
+ const placeholders = element.querySelectorAll(
+ '.embed-placeholder[data-type="youtube"][data-id]');
+ for (const placeholder of placeholders.values()) {
const embed = document.createElement('iframe');
- const url = 'http://www.youtube.com/embed/' +
- placeholders[i].getAttribute('data-id');
+ const url =
+ 'http://www.youtube.com/embed/' + placeholder.getAttribute('data-id');
embed.setAttribute('src', url);
embed.setAttribute('type', 'text/html');
embed.setAttribute('frameborder', '0');
- embedYoutubeIFrame(embed);
+
+ // The placeholder must be replaced with the new element before it is
+ // passed to embedYoutubeIFrame, which expects the element to have a
+ // parent.
+ const parent = placeholder.parentElement;
+ if (parent) {
+ parent.replaceChild(embed, placeholder);
+ embedYoutubeIFrame(embed);
+ }
}
}
diff --git a/components/dom_distiller/core/javascript/dom_distiller_viewer_main.js b/components/dom_distiller/core/javascript/dom_distiller_viewer_main.js
index 9d48a1de..f3c6290 100644
--- a/components/dom_distiller/core/javascript/dom_distiller_viewer_main.js
+++ b/components/dom_distiller/core/javascript/dom_distiller_viewer_main.js
@@ -31,23 +31,33 @@
* @param {HTMLElement} element The container element of the article.
*/
function postProcessElement(element) {
+ const tryStep = (fn) => {
+ try {
+ fn(element);
+ } catch (e) {
+ // Use a generic error message to avoid leaking potentially sensitive
+ // information from the DOM.
+ console.error('Post-processing step failed.');
+ }
+ };
+
// Remove surviving extraneous elements first so they don't interfere with
// downstream classifiers and processing.
- removeExtraneousElementsFrom(element);
+ tryStep(removeExtraneousElementsFrom);
// Wrap tables to make them scrollable.
- wrapTables(element);
+ tryStep(wrapTables);
// Readability will leave iframes around, but they need the proper structure
// and classes to be styled correctly.
- addClassesToYoutubeIFrames(element);
+ tryStep(addClassesToYoutubeIFrames);
// DomDistiller will leave placeholders, which need to be replaced with
// actual iframes.
- fillYouTubePlaceholders(element);
- sanitizeLinks(element);
- identifyEmptySVGs(element);
- ImageClassifier.processImagesIn(element);
- ListClassifier.processListsIn(element);
+ tryStep(fillYouTubePlaceholders);
+ tryStep(sanitizeLinks);
+ tryStep(identifyEmptySVGs);
+ tryStep((el) => ImageClassifier.processImagesIn(el));
+ tryStep((el) => ListClassifier.processListsIn(el));
}
function addToPage(html) {
diff --git a/components/test/data/dom_distiller/viewer_js_tester.js b/components/test/data/dom_distiller/viewer_js_tester.js
index 4ab8aa5..ed38e5e 100644
--- a/components/test/data/dom_distiller/viewer_js_tester.js
+++ b/components/test/data/dom_distiller/viewer_js_tester.js
@@ -22,4 +22,73 @@
'<a href="http://example.com" target="_blank">good link</a>' +
'bad linkanother bad link');
});
+
+ test('PostProcessElementCatchesExceptions', async function() {
+ const {assert} = await import('./index.js');
+
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+
+ // Save original functions.
+ const originalRemove = window.removeExtraneousElementsFrom;
+ const originalConsoleError = console.error;
+
+ let errorLogged = false;
+
+ // Mock functions.
+ window.removeExtraneousElementsFrom = () => {
+ throw new Error('Test Error');
+ };
+
+ console.error = (msg) => {
+ if (msg === 'Post-processing step failed.') {
+ errorLogged = true;
+ }
+ };
+
+ try {
+ postProcessElement(container);
+ } finally {
+ // Restore original functions.
+ window.removeExtraneousElementsFrom = originalRemove;
+ console.error = originalConsoleError;
+ }
+
+ assert.isTrue(errorLogged, 'Error should be logged to console');
+ });
+
+ test('PostProcessElementContinuesOnFailure', async function() {
+ const {assert} = await import('./index.js');
+
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+
+ // Save original functions.
+ const originalRemove = window.removeExtraneousElementsFrom;
+ const originalWrap = window.wrapTables;
+ const originalConsoleError = console.error;
+
+ let wrapCalled = false;
+
+ // Mock functions.
+ window.removeExtraneousElementsFrom = () => {
+ throw new Error('Test Error');
+ };
+ window.wrapTables = () => {
+ wrapCalled = true;
+ };
+ // Suppress console.error during this test to avoid polluting output.
+ console.error = () => {};
+
+ try {
+ postProcessElement(container);
+ } finally {
+ // Restore original functions.
+ window.removeExtraneousElementsFrom = originalRemove;
+ window.wrapTables = originalWrap;
+ console.error = originalConsoleError;
+ }
+
+ assert.isTrue(wrapCalled, 'Subsequent steps should still run');
+ });
});
Regression Test / PoC
diff --git a/components/test/data/dom_distiller/viewer_js_tester.js b/components/test/data/dom_distiller/viewer_js_tester.js
index 4ab8aa5..ed38e5e 100644
--- a/components/test/data/dom_distiller/viewer_js_tester.js
+++ b/components/test/data/dom_distiller/viewer_js_tester.js
@@ -22,4 +22,73 @@
'<a href="http://example.com" target="_blank">good link</a>' +
'bad linkanother bad link');
});
+
+ test('PostProcessElementCatchesExceptions', async function() {
+ const {assert} = await import('./index.js');
+
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+
+ // Save original functions.
+ const originalRemove = window.removeExtraneousElementsFrom;
+ const originalConsoleError = console.error;
+
+ let errorLogged = false;
+
+ // Mock functions.
+ window.removeExtraneousElementsFrom = () => {
+ throw new Error('Test Error');
+ };
+
+ console.error = (msg) => {
+ if (msg === 'Post-processing step failed.') {
+ errorLogged = true;
+ }
+ };
+
+ try {
+ postProcessElement(container);
+ } finally {
+ // Restore original functions.
+ window.removeExtraneousElementsFrom = originalRemove;
+ console.error = originalConsoleError;
+ }
+
+ assert.isTrue(errorLogged, 'Error should be logged to console');
+ });
+
+ test('PostProcessElementContinuesOnFailure', async function() {
+ const {assert} = await import('./index.js');
+
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+
+ // Save original functions.
+ const originalRemove = window.removeExtraneousElementsFrom;
+ const originalWrap = window.wrapTables;
+ const originalConsoleError = console.error;
+
+ let wrapCalled = false;
+
+ // Mock functions.
+ window.removeExtraneousElementsFrom = () => {
+ throw new Error('Test Error');
+ };
+ window.wrapTables = () => {
+ wrapCalled = true;
+ };
+ // Suppress console.error during this test to avoid polluting output.
+ console.error = () => {};
+
+ try {
+ postProcessElement(container);
+ } finally {
+ // Restore original functions.
+ window.removeExtraneousElementsFrom = originalRemove;
+ window.wrapTables = originalWrap;
+ console.error = originalConsoleError;
+ }
+
+ assert.isTrue(wrapCalled, 'Subsequent steps should still run');
+ });
});
Original Bug Report
DOM Distiller JS exception allows iOS Reading List navigation bypass
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the Chrome Security team.
Overview: An unhandled JavaScript exception in the iOS DOM Distiller’s post-processing halts execution before link sanitization occurs. This allows web-provided offline Reading List articles containing functional chrome:// links to bypass navigation policies and open internal WebUI pages.
Affected files:
components/dom_distiller/core/javascript/content_processing.jscomponents/dom_distiller/core/javascript/dom_distiller_viewer_main.jsios/web/navigation/crw_wk_navigation_handler.mmios/chrome/browser/reading_list/model/offline_page_tab_helper.mmios/web/web_state/ui/crw_web_request_controller.mm
Estimated timestamp from git blame: 2026-02-05
Potential Vulnerability Mechanism
The iOS Reading List offline viewer relies on the DOM Distiller to process and display saved web articles. When an offline article is loaded, the native C++ code injects the distilled HTML and executes a JavaScript function, postProcessElement, to format the content.
postProcessElement (in components/dom_distiller/core/javascript/dom_distiller_viewer_main.js) executes a fixed sequence of functions:
removeExtraneousElementsFrom(element)wrapTables(element)addClassesToYoutubeIFrames(element)fillYouTubePlaceholders(element)sanitizeLinks(element)
A logic error exists in fillYouTubePlaceholders (components/dom_distiller/core/javascript/content_processing.js). When it encounters a YouTube placeholder (embed-placeholder), it creates a new iframe element and immediately passes it to embedYoutubeIFrame(embed). Crucially, this iframe has not yet been appended to the DOM, meaning it is detached.
Inside embedYoutubeIFrame, the script attempts to read element.parentElement, which returns null for the detached element. It then calls parent.replaceChild(container, element), triggering a TypeError: Cannot read properties of null (reading 'replaceChild').
Because there is no try-catch block around the calls in postProcessElement, this unhandled exception completely halts the execution of the script. As a result, the subsequent call to sanitizeLinks(element) is bypassed.
Navigation Policy Bypass
sanitizeLinks is the primary defense intended to unwrap any links that do not use http:, https:, or # schemes. When it is bypassed, malicious chrome:// links embedded in the original article remain functional in the DOM.
When the offline viewer is presented, OfflinePageTabHelper::LoadData commits the page using the chrome://offline scheme. According to ChromeWebClient::IsAppSpecificURL, this is considered an app-specific URL.
If a user taps an unsanitized chrome:// link in the offline article, iOS WebKit triggers a navigation action. The handler, CRWWKNavigationHandler::shouldAllowAppSpecificURLNavigationAction, permits the navigation because the currently committed item (chrome://offline/...) is also an app-specific URL. The browser will successfully navigate to the target internal WebUI (e.g., chrome://flags), bypassing intended security boundaries.
Suggested Reproduction Steps
(Note: These are potential steps as our tooling agent cannot execute code.)
- Host a webpage structured as an article containing a valid YouTube iframe (e.g.,
<iframe src="https://www.youtube.com/embed/...">) and a link to an internal page (e.g.,<a href="chrome://flags">Settings</a>). - On Chrome iOS, have the user add the article to the Reading List.
- Disconnect from the network or throttle the connection to force the offline viewer to load via the slow-loading detection timer.
- Open the Reading List entry. The JS exception will occur during loading, leaving the
chrome://link intact. - Tap the link; the browser should navigate to
chrome://flags.
Suggested Fix
- Robust Error Handling: Wrap the individual function calls within
postProcessElementintry-catchblocks to ensure that a failure in one formatting step (like YouTube embedding) does not prevent critical security steps likesanitizeLinksfrom executing. - Fix the Logic Flaw: In
fillYouTubePlaceholders, ensure the newly creatediframeis appended to the DOM (e.g., replacing the placeholder element itself) before passing it to functions that expect it to have aparentElement.
Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.