Chrome · Mobile
CVE-2026-87545
Logic Error in Mobile
Overview
Low
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Changed Functions
| Function | Change | Notes |
|---|---|---|
options_components/dom_distiller/core/distiller.cc |
modified | |
dom_distiller_options_components/dom_distiller/core/distiller.cc |
modified | |
destruction_allowed_components/dom_distiller/core/distiller.cc |
modified | |
DistillerImplcomponents/dom_distiller/core/distiller.h |
modified |
Files Changed
components/dom_distiller/core/BUILD.gncomponents/dom_distiller/core/distiller.cccomponents/dom_distiller/core/distiller.hcomponents/dom_distiller/core/distiller_options.cccomponents/dom_distiller/core/distiller_options.h
Patch
From a21bacadecca0d465fbb2f28fabf1590439c06c0 Mon Sep 17 00:00:00 2001
From: Quentin Pubert <qpubert@google.com>
Date: Fri, 31 Jul 2026 05:45:07 -0700
Subject: [PATCH] [iOS][ReaderMode] Block non-trusted iframe requests in Reader Mode
Since Reader Mode is a simplified web page representation, it needs to
prevent tracking or other third-party iframes from loading and leaking
referrers.
This CL introduces a strict iframe domain allowlist matching the trusted
domains allowed by the Readability.js media/video engine.
- Declares a shared regex in C++ constants (kAllowedIframeRegex) in
ios/chrome/browser/dom_distiller/model/constants.h.
- Introduces DistillerOptions and ReadabilityOptions to cleanly pass
distillation configuration parameters down to Readability.js and
DOM Distiller.
- Injects this regex dynamically as a placeholder value
($$ALLOWED_VIDEO_REGEX) inside readability_distiller.js.
- Uses this identical regex in the iOS ReaderModeContentTabHelper policy
decider to block all subframe requests not matching the trusted
pattern (e.g. YouTube).
Bug: 523243507
Change-Id: I86cc4dbe5ae91a7d7bcf785cf8e1ca5e6a9cb717
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8019571
Reviewed-by: Brandon Wylie <wylieb@google.com>
Reviewed-by: Olivier Robin <olivierrobin@chromium.org>
Auto-Submit: Quentin Pubert <qpubert@google.com>
Commit-Queue: Quentin Pubert <qpubert@google.com>
Cr-Commit-Position: refs/heads/main@{#1671776}
---
diff --git a/components/dom_distiller/core/BUILD.gn b/components/dom_distiller/core/BUILD.gn
index 4f3d5124..cb070bf 100644
--- a/components/dom_distiller/core/BUILD.gn
+++ b/components/dom_distiller/core/BUILD.gn
@@ -56,6 +56,8 @@
"distilled_page_prefs.h",
"distiller.cc",
"distiller.h",
+ "distiller_options.cc",
+ "distiller_options.h",
"distiller_page.cc",
"distiller_page.h",
"distiller_ui_handle.h",
@@ -73,6 +75,7 @@
"extraction_utils.h",
"page_features.cc",
"page_features.h",
+ "readability_options.h",
"task_tracker.cc",
"task_tracker.h",
"url_constants.h",
diff --git a/components/dom_distiller/core/distiller.cc b/components/dom_distiller/core/distiller.cc
index 4d7f73f8..a80fe3b 100644
--- a/components/dom_distiller/core/distiller.cc
+++ b/components/dom_distiller/core/distiller.cc
@@ -34,16 +34,22 @@
DistillerFactoryImpl::DistillerFactoryImpl(
std::unique_ptr<DistillerURLFetcherFactory> distiller_url_fetcher_factory,
+ const DistillerOptions& options)
+ : distiller_url_fetcher_factory_(std::move(distiller_url_fetcher_factory)),
+ options_(options) {}
+
+DistillerFactoryImpl::DistillerFactoryImpl(
+ std::unique_ptr<DistillerURLFetcherFactory> distiller_url_fetcher_factory,
const dom_distiller::proto::DomDistillerOptions& dom_distiller_options)
: distiller_url_fetcher_factory_(std::move(distiller_url_fetcher_factory)),
- dom_distiller_options_(dom_distiller_options) {}
+ options_(dom_distiller_options) {}
DistillerFactoryImpl::~DistillerFactoryImpl() = default;
std::unique_ptr<Distiller> DistillerFactoryImpl::CreateDistiller() {
// This default implementation has the same behavior for all URLs.
- std::unique_ptr<DistillerImpl> distiller(new DistillerImpl(
- *distiller_url_fetcher_factory_, dom_distiller_options_));
+ std::unique_ptr<DistillerImpl> distiller(
+ new DistillerImpl(*distiller_url_fetcher_factory_, options_));
return std::move(distiller);
}
@@ -53,12 +59,18 @@
DistillerImpl::DistillerImpl(
const DistillerURLFetcherFactory& distiller_url_fetcher_factory,
- const dom_distiller::proto::DomDistillerOptions& dom_distiller_options)
+ const DistillerOptions& options)
: distiller_url_fetcher_factory_(distiller_url_fetcher_factory),
- dom_distiller_options_(dom_distiller_options),
+ options_(options),
max_pages_in_article_(kMaxPagesInArticle),
destruction_allowed_(true) {}
+DistillerImpl::DistillerImpl(
+ const DistillerURLFetcherFactory& distiller_url_fetcher_factory,
+ const dom_distiller::proto::DomDistillerOptions& dom_distiller_options)
+ : DistillerImpl(distiller_url_fetcher_factory,
+ DistillerOptions(dom_distiller_options)) {}
+
DistillerImpl::~DistillerImpl() {
DCHECK(destruction_allowed_);
}
@@ -128,7 +140,7 @@
// TODO(gilmanmh): Investigate whether this needs to be
// base::BindRepeating() or if base::BindOnce() can be used instead.
distiller_page_->DistillPage(
- url, dom_distiller_options_,
+ url, options_,
base::BindRepeating(&DistillerImpl::OnPageDistillationFinished,
weak_factory_.GetWeakPtr(), page_num, url));
}
diff --git a/components/dom_distiller/core/distiller.h b/components/dom_distiller/core/distiller.h
index b5051c9..330b398 100644
--- a/components/dom_distiller/core/distiller.h
+++ b/components/dom_distiller/core/distiller.h
@@ -18,9 +18,11 @@
#include "base/memory/ref_counted.h"
#include "base/memory/weak_ptr.h"
#include "components/dom_distiller/core/article_distillation_update.h"
+#include "components/dom_distiller/core/distiller_options.h"
#include "components/dom_distiller/core/distiller_page.h"
#include "components/dom_distiller/core/distiller_url_fetcher.h"
#include "components/dom_distiller/core/proto/distilled_article.pb.h"
+#include "components/dom_distiller/core/readability_options.h"
#include "third_party/abseil-cpp/absl/container/flat_hash_map.h"
#include "third_party/abseil-cpp/absl/container/flat_hash_set.h"
#include "url/gurl.h"
@@ -62,18 +64,23 @@
public:
DistillerFactoryImpl(
std::unique_ptr<DistillerURLFetcherFactory> distiller_url_fetcher_factory,
+ const DistillerOptions& options);
+ DistillerFactoryImpl(
+ std::unique_ptr<DistillerURLFetcherFactory> distiller_url_fetcher_factory,
const dom_distiller::proto::DomDistillerOptions& dom_distiller_options);
~DistillerFactoryImpl() override;
std::unique_ptr<Distiller> CreateDistiller() override;
private:
std::unique_ptr<DistillerURLFetcherFactory> distiller_url_fetcher_factory_;
- dom_distiller::proto::DomDistillerOptions dom_distiller_options_;
+ DistillerOptions options_;
};
// Distills a article from a page and associated pages.
class DistillerImpl : public Distiller {
public:
+ DistillerImpl(const DistillerURLFetcherFactory& distiller_url_fetcher_factory,
+ const DistillerOptions& options);
DistillerImpl(
const DistillerURLFetcherFactory& distiller_url_fetcher_factory,
const dom_distiller::proto::DomDistillerOptions& dom_distiller_options);
@@ -161,7 +168,7 @@
distiller_url_fetcher_factory_;
std::unique_ptr<DistillerPage> distiller_page_;
- dom_distiller::proto::DomDistillerOptions dom_distiller_options_;
+ DistillerOptions options_;
DistillationFinishedCallback finished_cb_;
DistillationUpdateCallback update_cb_;
diff --git a/components/dom_distiller/core/distiller_options.cc b/components/dom_distiller/core/distiller_options.cc
new file mode 100644
index 0000000..6b45b611
--- /dev/null
+++ b/components/dom_distiller/core/distiller_options.cc
@@ -0,0 +1,20 @@
+// 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.
+
+#include "components/dom_distiller/core/distiller_options.h"
+
+namespace dom_distiller {
+
+DistillerOptions::DistillerOptions() = default;
+
+DistillerOptions::DistillerOptions(
+ proto::DomDistillerOptions dom_distiller_options)
+ : dom_distiller(std::move(dom_distiller_options)) {}
+
+DistillerOptions::DistillerOptions(ReadabilityOptions readability_options)
+ : readability(std::move(readability_options)) {}
+
+DistillerOptions::~DistillerOptions() = default;
+
+} // namespace dom_distiller
diff --git a/components/dom_distiller/core/distiller_options.h b/components/dom_distiller/core/distiller_options.h
new file mode 100644
index 0000000..781f754
--- /dev/null
+++ b/components/dom_distiller/core/distiller_options.h
@@ -0,0 +1,29 @@
+// 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.
+
+#ifndef COMPONENTS_DOM_DISTILLER_CORE_DISTILLER_OPTIONS_H_
Loading diff…
Regression Test / PoC
shipped with the fix
diff --git a/components/dom_distiller/core/distiller_page_unittest.cc b/components/dom_distiller/core/distiller_page_unittest.cc
index e7bcea0f..a7c891eb 100644
--- a/components/dom_distiller/core/distiller_page_unittest.cc
+++ b/components/dom_distiller/core/distiller_page_unittest.cc
@@ -15,6 +15,7 @@
#include "components/dom_distiller/core/dom_distiller_constants.h"
#include "components/dom_distiller/core/dom_distiller_features.h"
#include "components/dom_distiller/core/extraction_utils.h"
+#include "components/dom_distiller/core/readability_options.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/dom_distiller_js/dom_distiller.pb.h"
#include "url/gurl.h"
@@ -308,6 +309,17 @@
}
#endif
+// Test that the readability script options are injected correctly.
+TEST_F(DistillerPageTest, ReadabilityScriptOptionsHandling) {
+ ReadabilityOptions custom_options;
+ std::string default_script = GetReadabilityDistillerScript(custom_options);
+ EXPECT_NE(std::string::npos, default_script.find("})(undefined);"));
+
+ custom_options.allowed_video_regex = "youtube|vimeo";
+ std::string custom_script = GetReadabilityDistillerScript(custom_options);
+ EXPECT_NE(std::string::npos, custom_script.find("})(\"youtube|vimeo\");"));
+}
+
} // namespace
} // namespace dom_distiller
diff --git a/ios/chrome/browser/reader_mode/model/reader_mode_content_tab_helper_unittest.mm b/ios/chrome/browser/reader_mode/model/reader_mode_content_tab_helper_unittest.mm
index 435d322..ab94297 100644
--- a/ios/chrome/browser/reader_mode/model/reader_mode_content_tab_helper_unittest.mm
+++ b/ios/chrome/browser/reader_mode/model/reader_mode_content_tab_helper_unittest.mm
@@ -150,22 +150,29 @@
EXPECT_NSEQ(non_content_request, delegate_->last_canceled_request());
}
-// Tests that non-main frame URL requests are always allowed. This is a
-// regression test for crbug.com/426443192.
-TEST_F(ReaderModeContentTabHelperTest, AllowsContentURLRequestForNonMainFrame) {
- NSURL* non_content_url = [NSURL URLWithString:@"https://test2.url/"];
- NSURLRequest* non_content_request =
- [NSURLRequest requestWithURL:non_content_url];
+// Tests that non-main frame URL requests are allowed if they are in the trusted
+// allowlist, and canceled otherwise.
+TEST_F(ReaderModeContentTabHelperTest, AllowsTrustedNonMainFrameRequestsOnly) {
+ NSURL* trusted_url =
+ [NSURL URLWithString:@"https://www.youtube.com/embed/123"];
+ NSURLRequest* trusted_request = [NSURLRequest requestWithURL:trusted_url];
web::WebStatePolicyDecider::RequestInfo non_main_frame_request_info(
ui::PAGE_TRANSITION_FIRST, /*target_frame_is_main=*/false, false, false,
false, false);
- // Non-main frame URLs should always be allowed.
std::optional<web::WebStatePolicyDecider::PolicyDecision> policy_decision =
- GetContentRequestPolicyDecision(non_content_request,
+ GetContentRequestPolicyDecision(trusted_request,
non_main_frame_request_info);
EXPECT_TRUE(policy_decision);
EXPECT_TRUE(policy_decision->ShouldAllowNavigation());
+
+ NSURL* untrusted_url = [NSURL URLWithString:@"https://test2.url/"];
+ NSURLRequest* untrusted_request = [NSURLRequest requestWithURL:untrusted_url];
+
+ policy_decision = GetContentRequestPolicyDecision(
+ untrusted_request, non_main_frame_request_info);
+ EXPECT_TRUE(policy_decision);
+ EXPECT_TRUE(policy_decision->ShouldCancelNavigation());
}
// Tests that the delegate is notified only when the loaded page URL matches the
diff --git a/ios/chrome/browser/reader_mode/model/reader_mode_test.mm b/ios/chrome/browser/reader_mode/model/reader_mode_test.mm
index c5a9a877..d0d965e 100644
--- a/ios/chrome/browser/reader_mode/model/reader_mode_test.mm
+++ b/ios/chrome/browser/reader_mode/model/reader_mode_test.mm
@@ -16,6 +16,7 @@
#import "components/optimization_guide/proto/hints.pb.h"
#import "components/sync/test/test_sync_service.h"
#import "components/translate/core/browser/translate_pref_names.h"
+#import "ios/chrome/browser/dom_distiller/model/constants.h"
#import "ios/chrome/browser/dom_distiller/model/distiller_service_factory.h"
#import "ios/chrome/browser/feature_engagement/model/tracker_factory.h"
#import "ios/chrome/browser/infobars/model/infobar_manager_impl.h"
@@ -24,6 +25,7 @@
#import "ios/chrome/browser/optimization_guide/model/optimization_guide_service.h"
#import "ios/chrome/browser/optimization_guide/model/optimization_guide_service_factory.h"
#import "ios/chrome/browser/overlays/model/public/overlay_request_queue.h"
+#import "ios/chrome/browser/reader_mode/model/constants.h"
#import "ios/chrome/browser/reader_mode/model/features.h"
#import "ios/chrome/browser/reader_mode/model/reader_mode_java_script_feature.h"
#import "ios/chrome/browser/reader_mode/model/reader_mode_scroll_anchor_java_script_feature.h"
@@ -164,8 +166,10 @@
// Set up the fake web frame to return a custom result after executing
// the Readability Javascript.
+ dom_distiller::ReadabilityOptions options;
+ options.allowed_video_regex = kReadabilityAllowedVideoRegex;
std::u16string readability_script =
- base::UTF8ToUTF16(dom_distiller::GetReadabilityDistillerScript());
+ base::UTF8ToUTF16(dom_distiller::GetReadabilityDistillerScript(options));
base::DictValue readability_result;
readability_result.Set("content", distilled_content);
readability_result.Set("title", "fake title");
Loading diff…
Original Bug Report
The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.
References
On This Page