CVE-2026-113249
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
CommandReceivingInterstitialPageios/components/security_interstitials/ios_security_interstitial_java_script_feature_unittest.mm |
modified | |
IOSSecurityInterstitialJavaScriptFeatureTestios/components/security_interstitials/ios_security_interstitial_java_script_feature_unittest.mm |
modified | |
source_setios/components/security_interstitials/BUILD.gn |
modified |
Files Changed
ios/components/security_interstitials/ios_security_interstitial_java_script_feature_unittest.mmios/components/security_interstitials/ios_security_interstitial_java_script_feature.mmios/components/security_interstitials/ios_security_interstitial_java_script_feature.hios/components/security_interstitials/BUILD.gn
Patch
From 83cfbdab5eb4135a83d1766ae4f8e43602179efc Mon Sep 17 00:00:00 2001 From: Hamza Mohammed <[email protected]> Date: Fri, 18 Sep 2026 09:05:42 -0700 Subject: [PATCH] [iOS] Require user interaction for security interstitial commands `IOSSecurityInterstitialJavaScriptFeature` listens in the page content world and forwards command messages from the main frame to the active blocking page. Since all legitimate interstitial actions (such as clicking "Proceed" or "Back to safety") are triggered by user gestures, drop messages where `script_message.is_user_interacting()` is false to prevent page scripts from programmatically invoking commands like `CMD_PROCEED`. Also add unit test coverage in `IOSSecurityInterstitialJavaScriptFeatureTest`. Fixed: 513480110 Change-Id: Iea8d3408c9b2628c31a87d3be2c53477ad29c56b Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8405171 Reviewed-by: Rohit Rao <[email protected]> Commit-Queue: Hamza Mohammed <[email protected]> Reviewed-by: Daniel White <[email protected]> Reviewed-by: jdh <[email protected]> Cr-Commit-Position: refs/heads/main@{#1700829} --- diff --git a/ios/components/security_interstitials/ios_security_interstitial_java_script_feature.h b/ios/components/security_interstitials/ios_security_interstitial_java_script_feature.h index 0834f9a..7bd07bc 100644 --- a/ios/components/security_interstitials/ios_security_interstitial_java_script_feature.h +++ b/ios/components/security_interstitials/ios_security_interstitial_java_script_feature.h @@ -21,6 +21,7 @@ private: friend class base::NoDestructor<IOSSecurityInterstitialJavaScriptFeature>; + friend class IOSSecurityInterstitialJavaScriptFeatureTest; // JavaScriptFeature overrides std::optional<std::string> GetScriptMessageHandlerName() const override; diff --git a/ios/components/security_interstitials/ios_security_interstitial_java_script_feature.mm b/ios/components/security_interstitials/ios_security_interstitial_java_script_feature.mm index c3d2cf8..e6d93a6 100644 --- a/ios/components/security_interstitials/ios_security_interstitial_java_script_feature.mm +++ b/ios/components/security_interstitials/ios_security_interstitial_java_script_feature.mm @@ -52,6 +52,12 @@ return; } + // Interstitial commands are only sent in response to user taps on the + // interstitial page. + if (!script_message.is_user_interacting()) { + return; + } + if (!script_message.legacy_body() || !script_message.legacy_body()->is_dict()) { return; diff --git a/ios/components/security_interstitials/ios_security_interstitial_java_script_feature_unittest.mm b/ios/components/security_interstitials/ios_security_interstitial_java_script_feature_unittest.mm new file mode 100644 index 0000000..626690c --- /dev/null +++ b/ios/components/security_interstitials/ios_security_interstitial_java_script_feature_unittest.mm @@ -0,0 +1,110 @@ +// 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 "ios/components/security_interstitials/ios_security_interstitial_java_script_feature.h" + +#import <optional> + +#import "base/memory/raw_ptr.h" +#import "base/strings/string_number_conversions.h" +#import "base/values.h" +#import "ios/components/security_interstitials/ios_blocking_page_tab_helper.h" +#import "ios/components/security_interstitials/ios_security_interstitial_page.h" +#import "ios/web/public/js_messaging/script_message.h" +#import "ios/web/public/test/fakes/fake_navigation_context.h" +#import "ios/web/public/test/fakes/fake_web_state.h" +#import "testing/gtest/include/gtest/gtest.h" +#import "testing/platform_test.h" +#import "url/gurl.h" +#import "url/origin.h" + +namespace security_interstitials { + +namespace { + +// An `IOSSecurityInterstitialPage` that records the last command it receives. +class CommandReceivingInterstitialPage : public IOSSecurityInterstitialPage { + public: + explicit CommandReceivingInterstitialPage(web::WebState* web_state) + : IOSSecurityInterstitialPage(web_state, + GURL(), + /*client=*/nullptr) {} + + std::optional<SecurityInterstitialCommand> last_command() const { + return last_command_; + } + + private: + void HandleCommand(SecurityInterstitialCommand command) override { + last_command_ = command; + } + bool ShouldCreateNewNavigation() const override { return false; } + void PopulateInterstitialStrings( + base::DictValue& load_time_data) const override {} + + std::optional<SecurityInterstitialCommand> last_command_; +}; + +} // namespace + +class IOSSecurityInterstitialJavaScriptFeatureTest : public PlatformTest { + protected: + IOSSecurityInterstitialJavaScriptFeatureTest() + : feature_(IOSSecurityInterstitialJavaScriptFeature::GetInstance()) { + IOSBlockingPageTabHelper::CreateForWebState(&web_state_); + + // Commit a navigation and associate a blocking page with it so that + // commands are routed to the page. + web::FakeNavigationContext context; + context.SetHasCommitted(true); + web_state_.OnNavigationFinished(&context); + auto page = std::make_unique<CommandReceivingInterstitialPage>(&web_state_); + page_ = page.get(); + IOSBlockingPageTabHelper::FromWebState(&web_state_) + ->AssociateBlockingPage(context.GetNavigationId(), std::move(page)); + } + + // Sends a script message containing `command` from the main frame. + void SendCommand( + SecurityInterstitialCommand command, + bool is_user_interacting, + std::optional<GURL> request_url = GURL("file:///error_page.html")) { + base::Value body(base::DictValue().Set( + "command", base::NumberToString(static_cast<int>(command)))); + web::ScriptMessage message( + std::make_unique<base::Value>(std::move(body)), is_user_interacting, + /*is_main_frame=*/true, request_url, url::Origin()); + feature_->ScriptMessageReceived(&web_state_, message); + } + + web::FakeWebState web_state_; + raw_ptr<IOSSecurityInterstitialJavaScriptFeature> feature_ = nullptr; + raw_ptr<CommandReceivingInterstitialPage> page_ = nullptr; +}; + +// Test that commands sent while the user is interacting with the page are +// dispatched to the blocking page. +TEST_F(IOSSecurityInterstitialJavaScriptFeatureTest, + DispatchesCommandWithUserInteraction) { + SendCommand(CMD_PROCEED, /*is_user_interacting=*/true); + EXPECT_EQ(CMD_PROCEED, page_->last_command()); +} + +// Test that commands sent without a user interaction are ignored. +TEST_F(IOSSecurityInterstitialJavaScriptFeatureTest, + IgnoresCommandWithoutUserInteraction) { + SendCommand(CMD_PROCEED, /*is_user_interacting=*/false); + EXPECT_FALSE(page_->last_command().has_value()); +} + +// Test that commands sent from non-file URLs are ignored even with user +// interaction. +TEST_F(IOSSecurityInterstitialJavaScriptFeatureTest, + IgnoresCommandFromNonFileUrl) { + SendCommand(CMD_PROCEED, /*is_user_interacting=*/true, + GURL("https://example.test/")); + EXPECT_FALSE(page_->last_command().has_value()); +} + +} // namespace security_interstitials diff --git a/ios/components/security_interstitials/BUILD.gn b/ios/components/security_interstitials/BUILD.gn index b8b727ef..3ed24e67 100644 --- a/ios/components/security_interstitials/BUILD.gn +++ b/ios/components/security_interstitials/BUILD.gn @@ -35,11 +35,17 @@ source_set("unit_tests") { testonly = true - sources = [ "ios_blocking_page_tab_helper_unittest.mm" ] + sources = [ + "ios_blocking_page_tab_helper_unittest.mm", + "ios_security_interstitial_java_script_feature_unittest.mm", + ] deps = [ ":security_interstitials", + "//base", "//ios/web", + "//ios/web/public/js_messaging", "//ios/web/public/test/fakes", "//testing/gtest", + "//url", ] }
Regression Test / PoC
diff --git a/ios/components/security_interstitials/ios_security_interstitial_java_script_feature_unittest.mm b/ios/components/security_interstitials/ios_security_interstitial_java_script_feature_unittest.mm
new file mode 100644
index 0000000..626690c
--- /dev/null
+++ b/ios/components/security_interstitials/ios_security_interstitial_java_script_feature_unittest.mm
@@ -0,0 +1,110 @@
+// 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 "ios/components/security_interstitials/ios_security_interstitial_java_script_feature.h"
+
+#import <optional>
+
+#import "base/memory/raw_ptr.h"
+#import "base/strings/string_number_conversions.h"
+#import "base/values.h"
+#import "ios/components/security_interstitials/ios_blocking_page_tab_helper.h"
+#import "ios/components/security_interstitials/ios_security_interstitial_page.h"
+#import "ios/web/public/js_messaging/script_message.h"
+#import "ios/web/public/test/fakes/fake_navigation_context.h"
+#import "ios/web/public/test/fakes/fake_web_state.h"
+#import "testing/gtest/include/gtest/gtest.h"
+#import "testing/platform_test.h"
+#import "url/gurl.h"
+#import "url/origin.h"
+
+namespace security_interstitials {
+
+namespace {
+
+// An `IOSSecurityInterstitialPage` that records the last command it receives.
+class CommandReceivingInterstitialPage : public IOSSecurityInterstitialPage {
+ public:
+ explicit CommandReceivingInterstitialPage(web::WebState* web_state)
+ : IOSSecurityInterstitialPage(web_state,
+ GURL(),
+ /*client=*/nullptr) {}
+
+ std::optional<SecurityInterstitialCommand> last_command() const {
+ return last_command_;
+ }
+
+ private:
+ void HandleCommand(SecurityInterstitialCommand command) override {
+ last_command_ = command;
+ }
+ bool ShouldCreateNewNavigation() const override { return false; }
+ void PopulateInterstitialStrings(
+ base::DictValue& load_time_data) const override {}
+
+ std::optional<SecurityInterstitialCommand> last_command_;
+};
+
+} // namespace
+
+class IOSSecurityInterstitialJavaScriptFeatureTest : public PlatformTest {
+ protected:
+ IOSSecurityInterstitialJavaScriptFeatureTest()
+ : feature_(IOSSecurityInterstitialJavaScriptFeature::GetInstance()) {
+ IOSBlockingPageTabHelper::CreateForWebState(&web_state_);
+
+ // Commit a navigation and associate a blocking page with it so that
+ // commands are routed to the page.
+ web::FakeNavigationContext context;
+ context.SetHasCommitted(true);
+ web_state_.OnNavigationFinished(&context);
+ auto page = std::make_unique<CommandReceivingInterstitialPage>(&web_state_);
+ page_ = page.get();
+ IOSBlockingPageTabHelper::FromWebState(&web_state_)
+ ->AssociateBlockingPage(context.GetNavigationId(), std::move(page));
+ }
+
+ // Sends a script message containing `command` from the main frame.
+ void SendCommand(
+ SecurityInterstitialCommand command,
+ bool is_user_interacting,
+ std::optional<GURL> request_url = GURL("file:///error_page.html")) {
+ base::Value body(base::DictValue().Set(
+ "command", base::NumberToString(static_cast<int>(command))));
+ web::ScriptMessage message(
+ std::make_unique<base::Value>(std::move(body)), is_user_interacting,
+ /*is_main_frame=*/true, request_url, url::Origin());
+ feature_->ScriptMessageReceived(&web_state_, message);
+ }
+
+ web::FakeWebState web_state_;
+ raw_ptr<IOSSecurityInterstitialJavaScriptFeature> feature_ = nullptr;
+ raw_ptr<CommandReceivingInterstitialPage> page_ = nullptr;
+};
+
+// Test that commands sent while the user is interacting with the page are
+// dispatched to the blocking page.
+TEST_F(IOSSecurityInterstitialJavaScriptFeatureTest,
+ DispatchesCommandWithUserInteraction) {
+ SendCommand(CMD_PROCEED, /*is_user_interacting=*/true);
+ EXPECT_EQ(CMD_PROCEED, page_->last_command());
+}
+
+// Test that commands sent without a user interaction are ignored.
+TEST_F(IOSSecurityInterstitialJavaScriptFeatureTest,
+ IgnoresCommandWithoutUserInteraction) {
+ SendCommand(CMD_PROCEED, /*is_user_interacting=*/false);
+ EXPECT_FALSE(page_->last_command().has_value());
+}
+
+// Test that commands sent from non-file URLs are ignored even with user
+// interaction.
+TEST_F(IOSSecurityInterstitialJavaScriptFeatureTest,
+ IgnoresCommandFromNonFileUrl) {
+ SendCommand(CMD_PROCEED, /*is_user_interacting=*/true,
+ GURL("https://example.test/"));
+ EXPECT_FALSE(page_->last_command().has_value());
+}
+
+} // namespace security_interstitials
Original Bug Report
Potential programmatic bypass of security interstitials on iOS
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 logic vulnerability in Chrome for iOS potentially allows a malicious website to programmatically bypass security interstitials (e.g., Enterprise ‘WARN’ and SSL interstitials). This occurs because the interstitial command handler fails to verify user interaction and can be triggered via cross-window scripting when the interstitial is committed with a web origin.
Affected files:
ios/chrome/browser/enterprise/connectors/ios_enterprise_interstitial.mmios/components/security_interstitials/ios_security_interstitial_java_script_feature.mmios/components/security_interstitials/ios_blocking_page_tab_helper.mmios/web/navigation/crw_wk_navigation_handler.mm
Estimated timestamp from git blame: 2025-04-08
Summary
A potential logic vulnerability in Chrome for iOS allows a malicious website to programmatically bypass security interstitials. The issue arises from a combination of inconsistent origin assignment during error page reloads and a lack of user-interaction validation when processing script messages from the interstitial page.
Vulnerability Details
1. Origin Spoofing via loadHTMLString:baseURL:
In ios/web/navigation/crw_wk_navigation_handler.mm, the method displayErrorPageWithError:inWebView:isProvisionalLoad: determines how to load an error or interstitial page. During a second navigation or reload of a blocked URL, the logic may enter an else branch (Line 2104) that executes:
errorNavigation = [webView loadHTMLString:@"" baseURL:errorPage.failedNavigationURL];
By using the failedNavigationURL (the blocked site) as the baseURL, the WKWebView commits the interstitial document with the security origin of the blocked site. This enables an attacker-controlled page from the same origin (e.g., an opener window) to programmatically access and script the interstitial document.
2. Lack of User-Interaction Validation
The IOSSecurityInterstitialJavaScriptFeature (in ios/components/security_interstitials/ios_security_interstitial_java_script_feature.mm) acts as the dispatcher for messages sent from the interstitial’s JavaScript. While it verifies that messages originate from the main frame, it fails to check the is_user_interacting property of the web::ScriptMessage.
Similarly, the command handlers for specific interstitials—such as EnterprisePageControllerClient::HandleCommand in ios/chrome/browser/enterprise/connectors/ios_enterprise_interstitial.mm and IOSSSLBlockingPage::HandleCommand in ios/chrome/browser/ssl/model/ios_ssl_blocking_page.mm—treat the CMD_PROCEED (ID 1) command as authoritative without verifying that a physical user interaction occurred.
Potential Attack Scenario
- A user on a managed device visits
https://attacker.test/opener(Tab A). - Tab A opens
https://attacker.test/warnedin a new tab (Tab B), which triggers an enterprise warning. - Tab A forces a reload of Tab B, causing the interstitial in Tab B to be committed with the
https://attacker.testorigin (as described in the Origin Spoofing section). - Since Tab A and Tab B are now same-origin, Tab A executes a script to send a proceed message to the browser process:
w.webkit.messageHandlers.IOSInterstitialMessage.postMessage({command: "1"});. - The browser process receives the command, allowlists the host
attacker.testfor the tab, and reloads the page, bypassing the security control without user consent.
Impact
- Policy Bypass: Silent defeat of administrator-configured security controls.
- Silent Allowlisting: The entire host is allowlisted for the lifetime of the tab after a single bypass.
- Audit Log Poisoning: The bypass triggers a
kBypassedevent in enterprise reporting logs, falsely attributing the action to a deliberate user decision.
Suggested Fix
- In
ios/components/security_interstitials/ios_security_interstitial_java_script_feature.mm, modifyScriptMessageReceivedto verifyscript_message.is_user_interacting()before processing sensitive commands likeCMD_PROCEED. - Review the error page loading logic in
ios/web/navigation/crw_wk_navigation_handler.mmto ensure that security-sensitive interstitials are consistently committed in a restricted origin (e.g.,file:///or a unique opaque origin).
Evaluated with Chrome root at commit: b3153093eb3c78c3e88ccf562bcbc20437a04b0e
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.