CVE-2026-106189
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
web_state_ios/chrome/browser/reader_mode/model/reader_mode_distiller_viewer.mm |
modified | |
ifios/chrome/browser/reader_mode/model/reader_mode_distiller_viewer.mm |
modified | |
TEST_Fcomponents/dom_distiller/core/viewer_unittest.cc |
modified |
Files Changed
ios/chrome/browser/reader_mode/model/reader_mode_distiller_viewer.hios/chrome/browser/reader_mode/model/BUILD.gnios/chrome/browser/reader_mode/model/reader_mode_distiller_viewer_unittest.mmcomponents/dom_distiller/core/viewer_unittest.ccios/chrome/browser/reader_mode/model/reader_mode_distiller_viewer.mmcomponents/dom_distiller/core/viewer.cc
Patch
From ca43a1a7b8859a5d4dca084e3074782379281054 Mon Sep 17 00:00:00 2001 From: Quentin Pubert <[email protected]> Date: Fri, 11 Sep 2026 02:53:45 -0700 Subject: [PATCH] [iOS] Restrict script execution in Reader Mode viewer with CSP This CL adds a nonce-based <meta> Content Security Policy (CSP) to the HTML template used by the Reader Mode viewer on iOS and updates the viewer bootstrap script to carry the matching nonce. This ensures script execution is constrained to the authorized viewer scripts and blocks un-nonced inline scripts and event handlers from executing. Context: In Chrome for iOS, Reader Mode commits distilled article HTML using WKWebView's `loadData:MIMEType:characterEncodingName:baseURL:` with the original article's URL as the base URL. Because `loadData:` does not inherit or carry HTTP response headers from the original page or server, the rendered document has no Content Security Policy by default. Adding a <meta> CSP ensures consistent policy enforcement across distilled markup loaded at that origin. Testing: - Added unit tests in `components/dom_distiller/core/viewer_unittest.cc` verifying that the template generates the nonce-based CSP meta tag when a nonce is provided and omits it when empty. - Added integration unit tests in `i/c/b/reader_mode/model/reader_mode_distiller_viewer_unittest.mm` verifying that inline event handlers and un-nonced scripts do not execute when loaded via WebState::LoadData(), while nonced scripts execute successfully. Fixed: 502034469 Change-Id: Ifb0f3433a48242c87e036e6f2645d8a23961128d Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8367146 Auto-Submit: Quentin Pubert <[email protected]> Commit-Queue: Quentin Pubert <[email protected]> Reviewed-by: Brandon Wylie <[email protected]> Reviewed-by: Olivier Robin <[email protected]> Cr-Commit-Position: refs/heads/main@{#1696072} --- diff --git a/components/dom_distiller/core/viewer.cc b/components/dom_distiller/core/viewer.cc index ee69928..6465fc3 100644 --- a/components/dom_distiller/core/viewer.cc +++ b/components/dom_distiller/core/viewer.cc @@ -225,6 +225,20 @@ csp << "form-action 'none'; "; csp << "base-uri 'none'; "; csp << "\">"; + } else if (!csp_nonce.empty()) { + // Reader mode (non-offline) viewer on iOS: the composed document is + // committed via -[WKWebView loadData:...baseURL:] at the original + // article's origin and carries no HTTP response headers, so without a + // <meta> policy it has no CSP at all. Restrict script execution to the + // nonced viewer script so that markup surviving distillation (e.g. on* + // event handler attributes or srcdoc iframes) cannot execute script at + // the article's origin. Styles/images/fonts are intentionally left + // unrestricted to keep the viewer functional. + csp << "<meta http-equiv=\"Content-Security-Policy\" content=\""; + csp << "script-src 'nonce-" << csp_nonce << "'; "; + csp << "object-src 'none'; "; + csp << "form-action 'none'; "; + csp << "\">"; } substitutions.push_back(csp.str()); // $1 substitutions.push_back(css.str()); // $2 diff --git a/ios/chrome/browser/reader_mode/model/reader_mode_distiller_viewer.h b/ios/chrome/browser/reader_mode/model/reader_mode_distiller_viewer.h index 6069e53e..94618c2a 100644 --- a/ios/chrome/browser/reader_mode/model/reader_mode_distiller_viewer.h +++ b/ios/chrome/browser/reader_mode/model/reader_mode_distiller_viewer.h @@ -57,6 +57,11 @@ // The url of the distilled page. const GURL url_; + // CSP nonce for the viewer's inline script. The viewer document is + // committed at the original article's origin without HTTP response + // headers, so a nonce-based <meta> CSP is required to prevent untrusted + // markup from executing script at that origin. + const std::string csp_nonce_; // Callback to run once distillation is complete. DistillationFinishedCallback callback_; // Whether the page is sufficiently initialized to handle updates from the diff --git a/ios/chrome/browser/reader_mode/model/reader_mode_distiller_viewer.mm b/ios/chrome/browser/reader_mode/model/reader_mode_distiller_viewer.mm index b37d332a..77d0d59a 100644 --- a/ios/chrome/browser/reader_mode/model/reader_mode_distiller_viewer.mm +++ b/ios/chrome/browser/reader_mode/model/reader_mode_distiller_viewer.mm @@ -9,6 +9,7 @@ #import "base/base64.h" #import "base/functional/bind.h" +#import "base/rand_util.h" #import "base/strings/utf_string_conversions.h" #import "components/dom_distiller/core/distilled_page_prefs.h" #import "components/dom_distiller/core/distiller.h" @@ -27,6 +28,7 @@ DistillationFinishedCallback callback) : DistillerViewerInterface(distiller_service->GetDistilledPagePrefs()), url_(url), + csp_nonce_(base::Base64Encode(base::RandBytesAsVector(16))), callback_(std::move(callback)), waiting_for_page_ready_(true), web_state_(web_state) { @@ -65,12 +67,13 @@ if (!is_empty) { const std::string html = dom_distiller::viewer::GetArticleTemplateHtml( distilled_page_prefs_->GetTheme(), - distilled_page_prefs_->GetFontFamily(), std::string(), + distilled_page_prefs_->GetFontFamily(), csp_nonce_, /*use_offline_data=*/false); std::string html_and_script(html); - html_and_script += "<script>" + buffer_ + "</script>"; + html_and_script += + "<script nonce=\"" + csp_nonce_ + "\">" + buffer_ + "</script>"; std::move(callback_).Run(url_, html_and_script, {}, article_proto->title(), - std::string()); + csp_nonce_); buffer_.clear(); waiting_for_page_ready_ = false; } else { diff --git a/components/dom_distiller/core/viewer_unittest.cc b/components/dom_distiller/core/viewer_unittest.cc index 1ab8b3b..8715a8c 100644 --- a/components/dom_distiller/core/viewer_unittest.cc +++ b/components/dom_distiller/core/viewer_unittest.cc @@ -199,4 +199,60 @@ #endif } +// Tests that GetArticleTemplateHtml includes a nonce-based +// Content-Security-Policy meta tag when a csp_nonce is provided and +// use_offline_data is false. +TEST_F(DomDistillerViewerTest, TestGetArticleTemplateHtmlHasCspWithNonce) { + const std::string html = viewer::GetArticleTemplateHtml( + mojom::Theme::kLight, mojom::FontFamily::kSansSerif, "test_nonce_12345", + /*use_offline_data=*/false); + EXPECT_NE(html.find("<meta http-equiv=\"Content-Security-Policy\""), + std::string::npos); + EXPECT_NE(html.find("script-src 'nonce-test_nonce_12345'"), + std::string::npos); + EXPECT_NE(html.find("object-src 'none'"), std::string::npos); + EXPECT_NE(html.find("form-action 'none'"), std::string::npos); + + // Values added only with use_offline_data should be absent. + EXPECT_EQ(html.find("default-src 'none'"), std::string::npos); + EXPECT_EQ(html.find("frame-src"), std::string::npos); + EXPECT_EQ(html.find("style-src"), std::string::npos); + EXPECT_EQ(html.find("font-src"), std::string::npos); + EXPECT_EQ(html.find("img-src"), std::string::npos); + EXPECT_EQ(html.find("base-uri 'none'"), std::string::npos); +} + +// Tests that GetArticleTemplateHtml omits the Content-Security-Policy meta tag +// when csp_nonce is empty and use_offline_data is false. +TEST_F(DomDistillerViewerTest, + TestGetArticleTemplateHtmlOmitsCspWhenNonceEmpty) { + const std::string html = viewer::GetArticleTemplateHtml( + mojom::Theme::kLight, mojom::FontFamily::kSansSerif, + /*csp_nonce=*/"", /*use_offline_data=*/false); + EXPECT_EQ(html.find("<meta http-equiv=\"Content-Security-Policy\""), + std::string::npos); +} + +// Tests that GetArticleTemplateHtml includes the offline +// Content-Security-Policy meta tag when use_offline_data is true. +TEST_F(DomDistillerViewerTest, TestGetArticleTemplateHtmlOfflineDataCsp) { + const std::string html = viewer::GetArticleTemplateHtml( + mojom::Theme::kLight, mojom::FontFamily::kSansSerif, "test_nonce_12345", + /*use_offline_data=*/true); + EXPECT_NE(html.find("<meta http-equiv=\"Content-Security-Policy\""), + std::string::npos); + EXPECT_NE(html.find("default-src 'none'"), std::string::npos); + EXPECT_NE(html.find("script-src 'nonce-test_nonce_12345'"), + std::string::npos); + EXPECT_NE(html.find("frame-src https://www.youtube.com"), std::string::npos); + EXPECT_NE(html.find("referrer strict-origin-when-cross-origin"), + std::string::npos); + EXPECT_NE(html.find("style-src 'unsafe-inline' https://fonts.googleapis.com"), + std::string::npos); + EXPECT_NE(html.find("font-src https://fonts.gstatic.com"), std::string::npos); + EXPECT_NE(html.find("img-src data:"), std::string::npos); + EXPECT_NE(html.find("form-action 'none'"), std::string::npos); + EXPECT_NE(html.find("base-uri 'none'"), std::string::npos); +} + } // namespace dom_distiller diff --git a/ios/chrome/browser/reader_mode/model/reader_mode_distiller_viewer_unittest.mm b/ios/chrome/browser/reader_mode/model/reader_mode_distiller_viewer_unittest.mm new file mode 100644 index 0000000..13e4594fc --- /dev/null +++ b/ios/chrome/browser/reader_mode/model/reader_mode_distiller_viewer_unittest.mm @@ -0,0 +1,148 @@ +// Copyright 2026 The Chromium Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <string> + +#import "base/test/ios/wait_util.h" +#import "components/dom_distiller/core/mojom/distilled_page_prefs.mojom.h" +#import "components/dom_distiller/core/viewer.h" +#import "ios/chrome/test/ios_chrome_test_with_web_state.h" +#import "ios/web/public/test/web_state_test_util.h" +#import "testing/gtest/include/gtest/gtest.h" +#import "testing/gtest_mac.h" +#import "url/gurl.h" + +namespace { + +constexpr char kTestArticleUrl[] = "https://example.com/article"; +constexpr char kTestCspNonce[] = "secret_nonce_12345678"; + +} // namespace + +// Test fixture verifying Content Security Policy enforcement for Reader Mode +// documents committed via WebState::LoadData(). +using ReaderModeDistillerViewerTest = IOSChromeTestWithWebState; + +// Tests that when Reader Mode HTML is generated with a CSP nonce and loaded via +// WebState::LoadData(), inline event handlers (e.g. onerror) do NOT execute, +// while nonced scripts execute successfully. +TEST_F(ReaderModeDistillerViewerTest, + TestCspBlocksInlineEventHandlersInLoadData) { + const GURL article_url(kTestArticleUrl); + const std::string csp_nonce(kTestCspNonce); + + // Load an initial page so the WebState commits a navigation item and realizes + // its web view, matching the state when Reader Mode loads data over an + // article. + web::test::LoadHtml(@"<html><body>Initial Page</body></html>", article_url, + web_state()); + + // Generate article template HTML with the CSP nonce in non-offline mode. + std::string html = dom_distiller::viewer::GetArticleTemplateHtml( + dom_distiller::mojom::Theme::kLight, + dom_distiller::mojom::FontFamily::kSansSerif, csp_nonce, + /*use_offline_data=*/false); + + // Embed an inline event handler on an image that fails to load (surviving + // distillation), an un-nonced script, and a valid nonced script representing + // the viewer bootstrap script. + html += "<div id='content'>"; + html += "<img src='data:image/png,malformed' " + "onerror='window.inlineEventHandlerRan = true'>"; + html += "</div>"; + html += "<script>window.unnoncedScriptRan = true;</script>"; + html += "<script nonce='" + csp_nonce + "'>"; + html += "window.noncedScriptRan = true;"; + html += "</script>"; + + NSData* data = [NSData dataWithBytes:html.data() length:html.length()]; + web_state()->LoadData(data, @"text/html", article_url); + + ASSERT_TRUE(base::test::ios::WaitUntilConditionOrTimeout( + base::test::ios::kWaitForPageLoadTimeout, ^bool { + return !web_state()->IsLoading(); + })); + + // Wait for the nonced script to execute. + ASSERT_TRUE(base::test::ios::WaitUntilConditionOrTimeout( + base::test::ios::kWaitForJSCompletionTimeout, ^bool { + id result = web::test::ExecuteJavaScript( + @"window.noncedScriptRan === true", web_state()); + return [result isEqual:@YES]; + })); + + id nonced_ran = web::test::ExecuteJavaScript( + @"window.noncedScriptRan === true", web_state()); + EXPECT_NSEQ(@YES, nonced_ran); + + // Verify that the inline event handler was blocked by CSP and did not + // execute. + id inline_event_handler_ran = web::test::ExecuteJavaScript( + @"window.inlineEventHandlerRan === true", web_state()); + EXPECT_NSEQ(@NO, inline_event_handler_ran); + + // Verify that the un-nonced script was blocked by CSP and did not execute. + id unnonced_ran = web::test::ExecuteJavaScript( + @"window.unnoncedScriptRan === true", web_state()); + EXPECT_NSEQ(@NO, unnonced_ran); +} + +// Tests that without a CSP nonce (the pre-fix condition), inline event +// handlers and un-nonced scripts DO execute, confirming that the CSP is +// responsible for blocking them. +TEST_F(ReaderModeDistillerViewerTest, + TestInlineEventHandlerExecutesWithoutCsp) { + const GURL article_url(kTestArticleUrl); + + // Load an initial page so the WebState commits a navigation item and realizes + // its web view, matching the state when Reader Mode loads data over an + // article. + web::test::LoadHtml(@"<html><body>Initial Page</body></html>", article_url, + web_state()); + + // Generate article template HTML without a CSP nonce. + std::string html = dom_distiller::viewer::GetArticleTemplateHtml( + dom_distiller::mojom::Theme::kLight, + dom_distiller::mojom::FontFamily::kSansSerif, + /*csp_nonce=*/"", + /*use_offline_data=*/false); + + html += "<div id='content'>"; + html += "<img src='data:image/png,malformed' " + "onerror='window.inlineEventHandlerRan = true'>"; + html += "</div>"; + html += "<script>window.unnoncedScriptRan = true;</script>"; + + NSData* data = [NSData dataWithBytes:html.data() length:html.length()]; + web_state()->LoadData(data, @"text/html", article_url); + + ASSERT_TRUE(base::test::ios::WaitUntilConditionOrTimeout( + base::test::ios::kWaitForPageLoadTimeout, ^bool { + return !web_state()->IsLoading(); + })); + + // Wait for the onerror handler to execute. + ASSERT_TRUE(base::test::ios::WaitUntilConditionOrTimeout( + base::test::ios::kWaitForJSCompletionTimeout, ^bool { + id result = web::test::ExecuteJavaScript( + @"window.inlineEventHandlerRan === true", web_state()); + return [result isEqual:@YES]; + })); + + id inline_event_handler_ran = web::test::ExecuteJavaScript( + @"window.inlineEventHandlerRan === true", web_state()); + EXPECT_NSEQ(@YES, inline_event_handler_ran); + + // Wait for the un-nonced script to execute. + ASSERT_TRUE(base::test::ios::WaitUntilConditionOrTimeout( + base::test::ios::kWaitForJSCompletionTimeout, ^bool { + id result = web::test::ExecuteJavaScript( + @"window.unnoncedScriptRan === true", web_state()); + return [result isEqual:@YES]; + })); + + id unnonced_ran = web::test::ExecuteJavaScript( + @"window.unnoncedScriptRan === true", web_state()); + EXPECT_NSEQ(@YES, unnonced_ran); +} diff --git a/ios/chrome/browser/reader_mode/model/BUILD.gn b/ios/chrome/browser/reader_mode/model/BUILD.gn index 57d56dd..df27144 100644 --- a/ios/chrome/browser/reader_mode/model/BUILD.gn +++ b/ios/chrome/browser/reader_mode/model/BUILD.gn @@ -202,6 +202,7 @@ "reader_mode_content_tab_helper_unittest.mm", "reader_mode_dependency_bridge_unittest.mm", "reader_mode_distiller_page_unittest.mm", + "reader_mode_distiller_viewer_unittest.mm", "reader_mode_font_size_utils_unittest.mm", "reader_mode_java_script_feature_unittest.mm", "reader_mode_metrics_helper_unittest.mm", @@ -217,6 +218,7 @@ "//base", "//base/test:test_support", "//components/dom_distiller/core", + "//components/dom_distiller/core/mojom", "//components/feature_engagement/test:test_support", "//components/optimization_guide/proto:optimization_guide_proto", "//components/sync:test_support", @@ -249,6 +251,7 @@ "//ios/chrome/browser/web/model:web_internal", "//ios/chrome/browser/web/model/web_view_proxy", "//ios/chrome/browser/web_selection/model", + "//ios/chrome/test:ios_chrome_test_with_web_state", "//ios/chrome/test:test_support", "//ios/chrome/test/fakes", "//ios/web/js_messaging:java_script_feature_manager_header", @@ -257,5 +260,6 @@ "//testing/gtest", "//third_party/ocmock", "//ui/base", + "//url", ] }
Regression Test / PoC
diff --git a/components/dom_distiller/core/viewer_unittest.cc b/components/dom_distiller/core/viewer_unittest.cc
index 1ab8b3b..8715a8c 100644
--- a/components/dom_distiller/core/viewer_unittest.cc
+++ b/components/dom_distiller/core/viewer_unittest.cc
@@ -199,4 +199,60 @@
#endif
}
+// Tests that GetArticleTemplateHtml includes a nonce-based
+// Content-Security-Policy meta tag when a csp_nonce is provided and
+// use_offline_data is false.
+TEST_F(DomDistillerViewerTest, TestGetArticleTemplateHtmlHasCspWithNonce) {
+ const std::string html = viewer::GetArticleTemplateHtml(
+ mojom::Theme::kLight, mojom::FontFamily::kSansSerif, "test_nonce_12345",
+ /*use_offline_data=*/false);
+ EXPECT_NE(html.find("<meta http-equiv=\"Content-Security-Policy\""),
+ std::string::npos);
+ EXPECT_NE(html.find("script-src 'nonce-test_nonce_12345'"),
+ std::string::npos);
+ EXPECT_NE(html.find("object-src 'none'"), std::string::npos);
+ EXPECT_NE(html.find("form-action 'none'"), std::string::npos);
+
+ // Values added only with use_offline_data should be absent.
+ EXPECT_EQ(html.find("default-src 'none'"), std::string::npos);
+ EXPECT_EQ(html.find("frame-src"), std::string::npos);
+ EXPECT_EQ(html.find("style-src"), std::string::npos);
+ EXPECT_EQ(html.find("font-src"), std::string::npos);
+ EXPECT_EQ(html.find("img-src"), std::string::npos);
+ EXPECT_EQ(html.find("base-uri 'none'"), std::string::npos);
+}
+
+// Tests that GetArticleTemplateHtml omits the Content-Security-Policy meta tag
+// when csp_nonce is empty and use_offline_data is false.
+TEST_F(DomDistillerViewerTest,
+ TestGetArticleTemplateHtmlOmitsCspWhenNonceEmpty) {
+ const std::string html = viewer::GetArticleTemplateHtml(
+ mojom::Theme::kLight, mojom::FontFamily::kSansSerif,
+ /*csp_nonce=*/"", /*use_offline_data=*/false);
+ EXPECT_EQ(html.find("<meta http-equiv=\"Content-Security-Policy\""),
+ std::string::npos);
+}
+
+// Tests that GetArticleTemplateHtml includes the offline
+// Content-Security-Policy meta tag when use_offline_data is true.
+TEST_F(DomDistillerViewerTest, TestGetArticleTemplateHtmlOfflineDataCsp) {
+ const std::string html = viewer::GetArticleTemplateHtml(
+ mojom::Theme::kLight, mojom::FontFamily::kSansSerif, "test_nonce_12345",
+ /*use_offline_data=*/true);
+ EXPECT_NE(html.find("<meta http-equiv=\"Content-Security-Policy\""),
+ std::string::npos);
+ EXPECT_NE(html.find("default-src 'none'"), std::string::npos);
+ EXPECT_NE(html.find("script-src 'nonce-test_nonce_12345'"),
+ std::string::npos);
+ EXPECT_NE(html.find("frame-src https://www.youtube.com"), std::string::npos);
+ EXPECT_NE(html.find("referrer strict-origin-when-cross-origin"),
+ std::string::npos);
+ EXPECT_NE(html.find("style-src 'unsafe-inline' https://fonts.googleapis.com"),
+ std::string::npos);
+ EXPECT_NE(html.find("font-src https://fonts.gstatic.com"), std::string::npos);
+ EXPECT_NE(html.find("img-src data:"), std::string::npos);
+ EXPECT_NE(html.find("form-action 'none'"), std::string::npos);
+ EXPECT_NE(html.find("base-uri 'none'"), std::string::npos);
+}
+
} // namespace dom_distiller
diff --git a/ios/chrome/browser/reader_mode/model/reader_mode_distiller_viewer_unittest.mm b/ios/chrome/browser/reader_mode/model/reader_mode_distiller_viewer_unittest.mm
new file mode 100644
index 0000000..13e4594fc
--- /dev/null
+++ b/ios/chrome/browser/reader_mode/model/reader_mode_distiller_viewer_unittest.mm
@@ -0,0 +1,148 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#import <string>
+
+#import "base/test/ios/wait_util.h"
+#import "components/dom_distiller/core/mojom/distilled_page_prefs.mojom.h"
+#import "components/dom_distiller/core/viewer.h"
+#import "ios/chrome/test/ios_chrome_test_with_web_state.h"
+#import "ios/web/public/test/web_state_test_util.h"
+#import "testing/gtest/include/gtest/gtest.h"
+#import "testing/gtest_mac.h"
+#import "url/gurl.h"
+
+namespace {
+
+constexpr char kTestArticleUrl[] = "https://example.com/article";
+constexpr char kTestCspNonce[] = "secret_nonce_12345678";
+
+} // namespace
+
+// Test fixture verifying Content Security Policy enforcement for Reader Mode
+// documents committed via WebState::LoadData().
+using ReaderModeDistillerViewerTest = IOSChromeTestWithWebState;
+
+// Tests that when Reader Mode HTML is generated with a CSP nonce and loaded via
+// WebState::LoadData(), inline event handlers (e.g. onerror) do NOT execute,
+// while nonced scripts execute successfully.
+TEST_F(ReaderModeDistillerViewerTest,
+ TestCspBlocksInlineEventHandlersInLoadData) {
+ const GURL article_url(kTestArticleUrl);
+ const std::string csp_nonce(kTestCspNonce);
+
+ // Load an initial page so the WebState commits a navigation item and realizes
+ // its web view, matching the state when Reader Mode loads data over an
+ // article.
+ web::test::LoadHtml(@"<html><body>Initial Page</body></html>", article_url,
+ web_state());
+
+ // Generate article template HTML with the CSP nonce in non-offline mode.
+ std::string html = dom_distiller::viewer::GetArticleTemplateHtml(
+ dom_distiller::mojom::Theme::kLight,
+ dom_distiller::mojom::FontFamily::kSansSerif, csp_nonce,
+ /*use_offline_data=*/false);
+
+ // Embed an inline event handler on an image that fails to load (surviving
+ // distillation), an un-nonced script, and a valid nonced script representing
+ // the viewer bootstrap script.
+ html += "<div id='content'>";
+ html += "<img src='data:image/png,malformed' "
+ "onerror='window.inlineEventHandlerRan = true'>";
+ html += "</div>";
+ html += "<script>window.unnoncedScriptRan = true;</script>";
+ html += "<script nonce='" + csp_nonce + "'>";
+ html += "window.noncedScriptRan = true;";
+ html += "</script>";
+
+ NSData* data = [NSData dataWithBytes:html.data() length:html.length()];
+ web_state()->LoadData(data, @"text/html", article_url);
+
+ ASSERT_TRUE(base::test::ios::WaitUntilConditionOrTimeout(
+ base::test::ios::kWaitForPageLoadTimeout, ^bool {
+ return !web_state()->IsLoading();
+ }));
+
+ // Wait for the nonced script to execute.
+ ASSERT_TRUE(base::test::ios::WaitUntilConditionOrTimeout(
+ base::test::ios::kWaitForJSCompletionTimeout, ^bool {
+ id result = web::test::ExecuteJavaScript(
+ @"window.noncedScriptRan === true", web_state());
+ return [result isEqual:@YES];
+ }));
+
+ id nonced_ran = web::test::ExecuteJavaScript(
+ @"window.noncedScriptRan === true", web_state());
+ EXPECT_NSEQ(@YES, nonced_ran);
+
+ // Verify that the inline event handler was blocked by CSP and did not
+ // execute.
+ id inline_event_handler_ran = web::test::ExecuteJavaScript(
+ @"window.inlineEventHandlerRan === true", web_state());
+ EXPECT_NSEQ(@NO, inline_event_handler_ran);
+
+ // Verify that the un-nonced script was blocked by CSP and did not execute.
+ id unnonced_ran = web::test::ExecuteJavaScript(
+ @"window.unnoncedScriptRan === true", web_state());
+ EXPECT_NSEQ(@NO, unnonced_ran);
+}
+
+// Tests that without a CSP nonce (the pre-fix condition), inline event
+// handlers and un-nonced scripts DO execute, confirming that the CSP is
+// responsible for blocking them.
+TEST_F(ReaderModeDistillerViewerTest,
+ TestInlineEventHandlerExecutesWithoutCsp) {
+ const GURL article_url(kTestArticleUrl);
+
+ // Load an initial page so the WebState commits a navigation item and realizes
+ // its web view, matching the state when Reader Mode loads data over an
+ // article.
+ web::test::LoadHtml(@"<html><body>Initial Page</body></html>", article_url,
+ web_state());
+
+ // Generate article template HTML without a CSP nonce.
+ std::string html = dom_distiller::viewer::GetArticleTemplateHtml(
+ dom_distiller::mojom::Theme::kLight,
+ dom_distiller::mojom::FontFamily::kSansSerif,
+ /*csp_nonce=*/"",
+ /*use_offline_data=*/false);
+
+ html += "<div id='content'>";
+ html += "<img src='data:image/png,malformed' "
+ "onerror='window.inlineEventHandlerRan = true'>";
+ html += "</div>";
+ html += "<script>window.unnoncedScriptRan = true;</script>";
+
+ NSData* data = [NSData dataWithBytes:html.data() length:html.length()];
+ web_state()->LoadData(data, @"text/html", article_url);
+
+ ASSERT_TRUE(base::test::ios::WaitUntilConditionOrTimeout(
+ base::test::ios::kWaitForPageLoadTimeout, ^bool {
+ return !web_state()->IsLoading();
+ }));
+
+ // Wait for the onerror handler to execute.
+ ASSERT_TRUE(base::test::ios::WaitUntilConditionOrTimeout(
+ base::test::ios::kWaitForJSCompletionTimeout, ^bool {
+ id result = web::test::ExecuteJavaScript(
+ @"window.inlineEventHandlerRan === true", web_state());
+ return [result isEqual:@YES];
+ }));
+
+ id inline_event_handler_ran = web::test::ExecuteJavaScript(
+ @"window.inlineEventHandlerRan === true", web_state());
+ EXPECT_NSEQ(@YES, inline_event_handler_ran);
+
+ // Wait for the un-nonced script to execute.
+ ASSERT_TRUE(base::test::ios::WaitUntilConditionOrTimeout(
+ base::test::ios::kWaitForJSCompletionTimeout, ^bool {
+ id result = web::test::ExecuteJavaScript(
+ @"window.unnoncedScriptRan === true", web_state());
+ return [result isEqual:@YES];
+ }));
+
+ id unnonced_ran = web::test::ExecuteJavaScript(
+ @"window.unnoncedScriptRan === true", web_state());
+ EXPECT_NSEQ(@YES, unnonced_ran);
+}
Original Bug Report
CSP Bypass and XSS in iOS Reader Mode via LoadData
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: iOS Reader Mode renders distilled content using LoadData with the original URL as the base URL, which drops the site’s original HTTP CSP headers. Because the distilled template lacks its own CSP and the distillation engine does not strip inline event handlers, a stored HTML injection on a site will execute in Reader Mode with full access to the victim origin’s cookies.
Affected files:
components/dom_distiller/core/viewer.ccios/chrome/browser/reader_mode/model/reader_mode_distiller_viewer.mmios/chrome/browser/reader_mode/model/reader_mode_content_tab_helper.mmcomponents/dom_distiller/core/javascript/dom_distiller_viewer_main.jscomponents/dom_distiller/core/html/dom_distiller_viewer.html
Estimated timestamp from git blame: 2026-01-23
Description
Chrome for iOS Reader Mode is vulnerable to a Content Security Policy (CSP) bypass that allows arbitrary JavaScript execution in the context of a victim site’s origin.
This issue occurs because Reader Mode renders distilled HTML at the victim’s first-party origin while simultaneously stripping the site’s original HTTP-delivered CSP and failing to inject its own viewer-level CSP.
Technical Details
- Unprotected Template Generation: The HTML template for the viewer is generated by calling
dom_distiller::viewer::GetArticleTemplateHtmlinios/chrome/browser/reader_mode/model/reader_mode_distiller_viewer.mm. This is called withuse_offline_data=false. Incomponents/dom_distiller/core/viewer.cc, whenuse_offline_datais false, the restrictive<meta http-equiv="Content-Security-Policy">block is omitted, leaving the viewer HTML with no CSP. - CSP Stripping via LoadData: The distilled document is loaded using
web_state()->LoadData(data, @"text/html", page_url)inios/chrome/browser/reader_mode/model/reader_mode_content_tab_helper.mm.LoadDatacalls[WKWebView loadData:MIMEType:characterEncodingName:baseURL:], using the original article’s URL as thebaseURL. Because the content is loaded as data rather than a network response, the original site’s HTTP response headers (including its CSP) are lost. However, the document executes within the security origin of the original site. - Incomplete Sanitization: On iOS, DOM distillation defaults to a modified
Readability.js. While this script removes<script>tags, it clones attributes when changing node tags and usesinnerHTML/appendChildwithout stripping inline event handlers (e.g.,onerror,onload). - Unsafe Insertion: The distilled HTML is inserted into the live DOM using
innerHTMLwithinaddToPageincomponents/dom_distiller/core/javascript/dom_distiller_viewer_main.js. This synchronously executes any surviving inline event handlers before any post-processing can occur. - Shared Data Store: The Reader Mode
WebStateuses the sameBrowserState(ProfileIOS) as the original tab. Therefore, any executed script has full access to the victim origin’sWKWebsiteDataStore(cookies,localStorage, etc.).
Potential Exploitation Steps
Note: These are suggested steps based on static analysis.
- An attacker identifies a target site (
https://victim.example) with a strict CSP (e.g.,script-src 'self') and finds a stored markup injection point (e.g., a comment section) where they can inject<img src="x" onerror="alert(document.cookie)">. - On the normal page, the site’s CSP blocks the
onerrorhandler from executing. - The victim opens the injected article in Chrome for iOS and activates Reader Mode.
- Reader Mode distills the page, preserving the
onerrorpayload, and loads it viaLoadDatainto a permissive context athttps://victim.example. - The payload executes with full access to the victim’s session data.
Suggested Fix
Ensure that a strict CSP is applied to the iOS Reader Mode viewer. This can be achieved by calling GetArticleTemplateHtml with use_offline_data=true to inject the restrictive CSP <meta> tag, or by modifying the iOS Reader Mode implementation to supply an equivalent CSP to the WKWebView context. Additionally, hardening Readability.js to strip on* attributes during extraction would provide defense-in-depth.
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.