Medium chrome Input Validation 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactImproper input validation in Mobile
DescriptionImproper input validation in Mobile
ComponentMobile
Sub-componentiOS
Bug ClassInput Validation
Tracker513480110
Fix commit83cfbdab5eb4 (chromium/src) +124/-1
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
CommandReceivingInterstitialPage
ios/components/security_interstitials/ios_security_interstitial_java_script_feature_unittest.mm
modified
IOSSecurityInterstitialJavaScriptFeatureTest
ios/components/security_interstitials/ios_security_interstitial_java_script_feature_unittest.mm
modified
source_set
ios/components/security_interstitials/BUILD.gn
modified

Files Changed

  • ios/components/security_interstitials/ios_security_interstitial_java_script_feature_unittest.mm
  • ios/components/security_interstitials/ios_security_interstitial_java_script_feature.mm
  • ios/components/security_interstitials/ios_security_interstitial_java_script_feature.h
  • ios/components/security_interstitials/BUILD.gn
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",
   ]
 }
Loading diff…

Regression Test / PoC

shipped with the fix
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
Loading diff…

Original Bug Report

reported by [email protected]

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.mm
  • ios/components/security_interstitials/ios_security_interstitial_java_script_feature.mm
  • ios/components/security_interstitials/ios_blocking_page_tab_helper.mm
  • ios/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

  1. A user on a managed device visits https://attacker.test/opener (Tab A).
  2. Tab A opens https://attacker.test/warned in a new tab (Tab B), which triggers an enterprise warning.
  3. Tab A forces a reload of Tab B, causing the interstitial in Tab B to be committed with the https://attacker.test origin (as described in the Origin Spoofing section).
  4. 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"});.
  5. The browser process receives the command, allowlists the host attacker.test for 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 kBypassed event in enterprise reporting logs, falsely attributing the action to a deliberate user decision.

Suggested Fix

  1. In ios/components/security_interstitials/ios_security_interstitial_java_script_feature.mm, modify ScriptMessageReceived to verify script_message.is_user_interacting() before processing sensitive commands like CMD_PROCEED.
  2. Review the error page loading logic in ios/web/navigation/crw_wk_navigation_handler.mm to 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.

View on issue tracker