CVE-2026-13983
Overview
Files Changed
ios/chrome/browser/reader_mode/model/reader_mode_tab_helper.mmios/chrome/browser/reader_mode/ui/BUILD.gnios/chrome/browser/reader_mode/ui/reader_mode_egtest.mm
Patch
From e4314a903c519e22e00ed52b2785afa9017bfd83 Mon Sep 17 00:00:00 2001 From: Quentin Pubert <[email protected]> Date: Fri, 22 May 2026 03:47:11 -0700 Subject: [PATCH] [iOS] Fix Reader mode spoofing via misclassified navigation initiation When link navigation is intercepted inside Reader Mode overlay, the request is re-issued on the host WebState. Previously, is_renderer_initiated was not set, defaulting to false (browser-initiated). Because browser-initiated navigations are trusted, the location view was updated to show the pending URL immediately while the attacker-controlled distilled view was still shown in the overlay. This CL sets is_renderer_initiated to true in the WebLoadParams, correctly identifying the navigation as renderer-initiated and preventing URL spoofing. This CL also adds an EarlGrey test testReaderModeLinkNavigationRendererInitiated to verify that the pending URL is not shown before commitment. Fixed: 514009910 Change-Id: Ic5747be68b6b96b734249805b3c54e30af068d27 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7864203 Auto-Submit: Quentin Pubert <[email protected]> Reviewed-by: Olivier Robin <[email protected]> Commit-Queue: Quentin Pubert <[email protected]> Commit-Queue: Olivier Robin <[email protected]> Cr-Commit-Position: refs/heads/main@{#1634848} --- diff --git a/ios/chrome/browser/reader_mode/model/reader_mode_tab_helper.mm b/ios/chrome/browser/reader_mode/model/reader_mode_tab_helper.mm index 275008d4..1f54be9 100644 --- a/ios/chrome/browser/reader_mode/model/reader_mode_tab_helper.mm +++ b/ios/chrome/browser/reader_mode/model/reader_mode_tab_helper.mm @@ -381,6 +381,7 @@ params.referrer.policy = web::ReferrerPolicyStrictOriginWhenCrossOrigin; } params.transition_type = request_info.transition_type; + params.is_renderer_initiated = true; web_state_->GetNavigationManager()->LoadURLWithParams(params); } diff --git a/ios/chrome/browser/reader_mode/ui/BUILD.gn b/ios/chrome/browser/reader_mode/ui/BUILD.gn index fa795c9..37c90e6f 100644 --- a/ios/chrome/browser/reader_mode/ui/BUILD.gn +++ b/ios/chrome/browser/reader_mode/ui/BUILD.gn @@ -107,6 +107,7 @@ "//ios/chrome/browser/shared/public/features", "//ios/chrome/browser/signin/model:fake_system_identity", "//ios/chrome/browser/text_zoom/ui_bundled:constants", + "//ios/chrome/test:eg_test_support+eg2", "//ios/chrome/test/earl_grey:eg_test_support+eg2", "//ios/testing/earl_grey:eg_test_support+eg2", "//net:test_support", diff --git a/ios/chrome/browser/reader_mode/ui/reader_mode_egtest.mm b/ios/chrome/browser/reader_mode/ui/reader_mode_egtest.mm index 42f7924..a473947f 100644 --- a/ios/chrome/browser/reader_mode/ui/reader_mode_egtest.mm +++ b/ios/chrome/browser/reader_mode/ui/reader_mode_egtest.mm @@ -6,6 +6,7 @@ #import "base/strings/stringprintf.h" #import "base/strings/sys_string_conversions.h" +#import "base/test/ios/wait_util.h" #import "components/dom_distiller/core/dom_distiller_features.h" #import "components/dom_distiller/core/mojom/distilled_page_prefs.mojom.h" #import "components/dom_distiller/core/pref_names.h" @@ -36,9 +37,12 @@ #import "ios/chrome/test/earl_grey/chrome_earl_grey_ui.h" #import "ios/chrome/test/earl_grey/chrome_matchers.h" #import "ios/chrome/test/earl_grey/chrome_test_case.h" +#import "ios/chrome/test/scoped_eg_synchronization_disabler.h" #import "ios/testing/earl_grey/earl_grey_test.h" #import "ios/testing/earl_grey/matchers.h" #import "net/test/embedded_test_server/default_handlers.h" +#import "net/test/embedded_test_server/http_request.h" +#import "net/test/embedded_test_server/http_response.h" #import "testing/gmock/include/gmock/gmock-matchers.h" #import "ui/base/l10n/l10n_util.h" @@ -125,6 +129,27 @@ grey_interactable(), nil); } +// Handles requests for Reader Mode link navigation test case. +std::unique_ptr<net::test_server::HttpResponse> HandleReaderModeTestRequests( + const net::test_server::HttpRequest& request) { + if (request.GetURL().path() == "/distillable_page") { + auto response = std::make_unique<net::test_server::BasicHttpResponse>(); + response->set_code(net::HTTP_OK); + response->set_content( + "<!DOCTYPE html><html><body><article>" + "<h1>Distillable Article Title</h1>" + "<p>Here is some paragraph text that makes this page look like a " + "real article to the DOM Distiller.</p>" + "<a id='spoof' href='/victim'>Link to hung victim page</a>" + "</article></body></html>"); + return response; + } + if (request.GetURL().path() == "/victim") { + return std::make_unique<net::test_server::HungResponse>(); + } + return nullptr; +} + } // namespace // Tests interactions with Reader Mode on a web page. @@ -143,6 +168,8 @@ forUserPref:translate::prefs::kOfferTranslateEnabled]; [ChromeEarlGrey setBoolValue:YES forUserPref:prefs::kIOSBwgConsent]; + self.testServer->RegisterRequestHandler( + base::BindRepeating(&HandleReaderModeTestRequests)); net::test_server::RegisterDefaultHandlers(self.testServer); GREYAssertTrue(self.testServer->Start(), @"Server did not start."); @@ -1467,4 +1494,32 @@ [self assertReaderModePageIsHidden]; } +// Tests that tapping a link inside Reader mode results in a renderer-initiated +// navigation that does not display a pending navigation in the omnibox. +- (void)testReaderModeLinkNavigationRendererInitiated { + GURL distillableURL = self.testServer->GetURL("/distillable_page"); + + // Navigate to the distillable page and mark it eligible via optimization + // guide. + [self loadURLWithOptimizationGuideHints:distillableURL]; + + // Open Reader Mode UI. + GREYAssertTrue( + [ChromeEarlGrey showReaderModeAndWaitUntilReaderModeWebStateIsReady], + @"Reader mode content could not be loaded"); + [self assertReaderModePageIsVisible]; + + // Disable EarlGrey synchronization because the navigation to /victim hangs. + ScopedSynchronizationDisabler disabler; + + // Tap the spoof link inside the distilled Reader view. + [ChromeEarlGrey tapWebStateElementWithID:@"spoof"]; + + // Immediately verify that the pending navigation item's URL (the victim page) + // is NOT visible in the omnibox, i.e. webStateVisibleURL remains the + // distillable page. + GREYAssertEqual([ChromeEarlGrey webStateVisibleURL], distillableURL, + @"Visible URL should remain the distillable page."); +} + @end
Original Bug Report
Potential URL Spoofing in iOS Reader Mode via Misclassified Navigation Initiation
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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A potential vulnerability in iOS Reader Mode allows an attacker to spoof the URL in the omnibox by exploiting a missing renderer-initiated flag during link navigation. This causes the navigation to be incorrectly treated as browser-initiated, updating the omnibox to a target URL while attacker-controlled content remains visible in the overlay.
Affected files:
ios/chrome/browser/reader_mode/model/reader_mode_tab_helper.mm
Estimated timestamp from git blame: Unknown (Google3 checkout)
Technical Description
In Chrome for iOS, Reader Mode utilizes a secondary WebState displayed in an overlay to render distilled page content. When a user interacts with a link inside this distilled view, the navigation request is intercepted by the overlay’s policy decider and re-issued on the host (original) WebState to perform the navigation.
A potential vulnerability exists in ios/chrome/browser/reader_mode/model/reader_mode_tab_helper.mm within the ReaderModeContentDidCancelRequest method. This method constructs web::NavigationManager::WebLoadParams for the host WebState but fails to set the is_renderer_initiated flag to true.
// ios/chrome/browser/reader_mode/model/reader_mode_tab_helper.mm
void ReaderModeTabHelper::ReaderModeContentDidCancelRequest(...) {
// ...
web::NavigationManager::WebLoadParams params(net::GURLWithNSURL(request.URL));
// BUG: params.is_renderer_initiated defaults to false (browser-initiated).
// ...
web_state_->GetNavigationManager()->LoadURLWithParams(params);
}
Because is_renderer_initiated defaults to false in web::NavigationManager::WebLoadParams, the navigation is classified as NavigationInitiationType::BROWSER_INITIATED by the navigation manager.
The URL spoofing guard logic in NavigationManagerImpl::GetVisibleItem() (ios/web/navigation/navigation_manager_impl.mm) allows a pending navigation item to be displayed in the omnibox immediately if it is considered browser-initiated. Browser-initiated navigations (like typing in the omnibox or clicking a bookmark) are trusted to update the location bar immediately, whereas renderer-initiated navigations (like link clicks) are typically hidden until they commit to prevent spoofing.
An attacker can exploit this by linking to a slow-responding URL (e.g., a hanging port). When the user clicks the link in Reader Mode, the omnibox immediately updates to show the pending ’trusted’ URL. However, the distilled view (controlled by the attacker) continues to be displayed in the overlay until the navigation commits or times out, as the overlay is only deactivated upon DidFinishNavigation.
Potential Reproduction Steps
- Navigate to an attacker-controlled page that is eligible for Reader Mode.
- The page should contain a link to a target domain on a hanging port, for example:
<a href="https://example.com:81/">Click here to login</a>. - Activate Reader Mode to show the distilled overlay.
- Tap the link within the distilled view.
- Observe that the omnibox may immediately update to
https://example.com:81/, while the content area continues to display the attacker’s distilled content until the navigation eventually fails or commits.
Recommended Fix
The ReaderModeTabHelper::ReaderModeContentDidCancelRequest method should explicitly set the is_renderer_initiated flag to true in the WebLoadParams object. This ensures the navigation is correctly identified as renderer-initiated, preventing the pending URL from being displayed in the omnibox before commitment.
params.is_renderer_initiated = true;
Evaluated with Chrome root at commit: 1a8d40fc44df2088d5945c0bf53584038aa1614a
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
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.