Low chrome Logic Error 🔧 Commit mapped

Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in Chrome for iOS
DescriptionInappropriate implementation in Chrome for iOS
ComponentChrome for iOS
Bug ClassLogic Error
Tracker521934304
Fix commit0c06681f1fa5 (chromium/src) +88/-131
CISA KEVNot listed
CreditedGoogle
Disclosed2026-07-29

Changed Functions

FunctionChangeNotes
if
components/password_manager/ios/password_form_helper.mm
modified
TEST_F
components/password_manager/ios/password_form_helper_unittest.mm
modified
if
components/password_manager/ios/resources/password_controller.ts
modified
TEST_F
ios/chrome/browser/passwords/model/password_controller_js_unittest.mm
modified

Files Changed

  • components/password_manager/ios/password_form_helper.h
  • components/password_manager/ios/password_form_helper.mm
  • components/password_manager/ios/password_form_helper_unittest.mm
  • components/password_manager/ios/resources/password_controller.ts
  • ios/chrome/browser/passwords/model/password_controller_egtest.mm
  • ios/chrome/browser/passwords/model/password_controller_js_unittest.mm
From 0c06681f1fa50bcd7dbf942d514fb9bce32c859c Mon Sep 17 00:00:00 2001
From: Alexis Hétu <sugoi@chromium.org>
Date: Thu, 11 Jun 2026 15:33:49 -0700
Subject: [PATCH] [iOS] Reject untrusted password form submission messages

Ensure password form submission messages are processed only when
resulting from explicit user interaction, preventing untrusted
scripts or synthetic events from triggering form submissions.

Bug: 521934304
Change-Id: I175cce2d4ff6d7959f1d6acbe97f11cab2f08fd2
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7919149
Reviewed-by: Mohamed Amir Yosef <mamir@chromium.org>
Reviewed-by: Vincent Boisselle <vincb@google.com>
Commit-Queue: Alexis Hétu <sugoi@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1645654}
---

diff --git a/components/password_manager/ios/password_form_helper.h b/components/password_manager/ios/password_form_helper.h
index 07fc1b92..742d12a 100644
--- a/components/password_manager/ios/password_form_helper.h
+++ b/components/password_manager/ios/password_form_helper.h
@@ -45,7 +45,8 @@
   kRejectedNoFrameMatchingId = 4,
   kRejectedNoTrustedUrl = 5,
   kRejectedCantExtractFormData = 6,
-  kMaxValue = kRejectedCantExtractFormData
+  kRejectedNoUserInteraction = 7,
+  kMaxValue = kRejectedNoUserInteraction
 };
 
 // A protocol implemented by a delegate of PasswordFormHelper.
diff --git a/components/password_manager/ios/password_form_helper.mm b/components/password_manager/ios/password_form_helper.mm
index 626a998..2c38999 100644
--- a/components/password_manager/ios/password_form_helper.mm
+++ b/components/password_manager/ios/password_form_helper.mm
@@ -385,6 +385,10 @@
     return HandleSubmittedFormStatus::kRejectedNoDelegate;
   }
 
+  if (!message.is_user_interacting()) {
+    return HandleSubmittedFormStatus::kRejectedNoUserInteraction;
+  }
+
   std::optional<GURL> pageURL = _webState->GetLastCommittedURLIfTrusted();
   if (!pageURL) {
     return HandleSubmittedFormStatus::kRejectedNoTrustedUrl;
diff --git a/components/password_manager/ios/password_form_helper_unittest.mm b/components/password_manager/ios/password_form_helper_unittest.mm
index 4f46daf3..e3871520 100644
--- a/components/password_manager/ios/password_form_helper_unittest.mm
+++ b/components/password_manager/ios/password_form_helper_unittest.mm
@@ -1067,6 +1067,28 @@
   EXPECT_OCMOCK_VERIFY(delegate);
 }
 
+// Tests that the form submit message isn't handled when user interaction is
+// absent.
+TEST_F(PasswordFormHelperTest, HandleFormSubmittedMessage_NoUserInteraction) {
+  id delegate = OCMStrictProtocolMock(@protocol(PasswordFormHelperDelegate));
+  helper_.delegate = delegate;
+
+  LoadHtml(@"<p>");
+
+  web::ScriptMessage submit_message(
+      ValidFormSubmittedMessageBody(GetMainFrame()->GetFrameId()),
+      /*is_user_interacting=*/false,
+      /*is_main_frame=*/true,
+      /*request_url=*/std::nullopt, url::Origin());
+
+  HandleSubmittedFormStatus status =
+      [helper_ handleFormSubmittedMessage:submit_message];
+
+  EXPECT_EQ(HandleSubmittedFormStatus::kRejectedNoUserInteraction, status);
+
+  EXPECT_OCMOCK_VERIFY(delegate);
+}
+
 }  // namespace
 
 NS_ASSUME_NONNULL_END
diff --git a/components/password_manager/ios/resources/password_controller.ts b/components/password_manager/ios/resources/password_controller.ts
index 5459c79f..7d2e391b 100644
--- a/components/password_manager/ios/resources/password_controller.ts
+++ b/components/password_manager/ios/resources/password_controller.ts
@@ -86,7 +86,13 @@
  * Click handler for the submit button.
  */
 function onSubmitButtonTouchEnd(evt: Event) {
-  const form = (evt.currentTarget as HTMLFormElement)['form'];
+  if (!evt.isTrusted) {
+    return;
+  }
+  const form = (evt.currentTarget as HTMLButtonElement).form;
+  if (!form) {
+    return;
+  }
   const formData = getPasswordFormData(form);
   if (!formData) {
     return;
diff --git a/ios/chrome/browser/passwords/model/password_controller_egtest.mm b/ios/chrome/browser/passwords/model/password_controller_egtest.mm
index b7624e69..5b9cff1 100644
--- a/ios/chrome/browser/passwords/model/password_controller_egtest.mm
+++ b/ios/chrome/browser/passwords/model/password_controller_egtest.mm
@@ -267,7 +267,10 @@
   }
 
   if ([self isRunningTest:@selector(testSavePromptAppearsOnFormSubmission)] ||
-      [self isRunningTest:@selector(testUpdatePromptAppearsOnFormSubmission)]) {
+      [self isRunningTest:@selector(testUpdatePromptAppearsOnFormSubmission)] ||
+      [self isRunningTest:@selector(
+                              testSyntheticTouchendOnBtnElementIsIgnored)] ||
+      [self isRunningTest:@selector(testProgrammaticSubmissionFails)]) {
     // These tests need a badge.
     config.features_disabled.push_back(kAutofillBadgeRemoval);
   }
@@ -367,6 +370,52 @@
   GREYAssertEqual(1, credentialsCount, @"Wrong number of stored credentials.");
 }
 
+// Tests that a synthetic touchend event on a <button> embedded in a password
+// form is ignored and does not act as a submission indicator.
+- (void)testSyntheticTouchendOnBtnElementIsIgnored {
+  [self loadLoginPage];
+
+  // Simulate user interacting with fields to trigger a capture of credentials.
+  [[EarlGrey selectElementWithMatcher:chrome_test_util::WebViewMatcher()]
+      performAction:chrome_test_util::TapWebElementWithId(kFormUsername)];
+  [[EarlGrey selectElementWithMatcher:chrome_test_util::WebViewMatcher()]
+      performAction:chrome_test_util::TapWebElementWithId(kFormPassword)];
+
+  [[EarlGrey selectElementWithMatcher:chrome_test_util::WebViewMatcher()]
+      performAction:chrome_test_util::TapWebElementWithId("submit_button")];
+
+  // Wait until the save password prompt becomes visible.
+  [ChromeEarlGrey
+      waitForUIElementToAppearWithMatcher:
+          PasswordInfobarLabels(IDS_IOS_PASSWORD_MANAGER_SAVE_PASSWORD_PROMPT)];
+}
+
+// Tests that programmatic submission without a trusted user interaction state
+// fails and does not offer to save passwords.
+- (void)testProgrammaticSubmissionFails {
+  [self loadLoginPage];
+
+  NSString* script =
+      @"document.getElementById('un').value = 'user1';"
+      @"document.getElementById('pw').value = 'password1';"
+      @"var e = new UIEvent('touchend');"
+      @"document.getElementById('submit_button').dispatchEvent(e);";
+  [ChromeEarlGrey evaluateJavaScriptForSideEffect:script];
+
+  // Allow some time for any potential infobar to appear.
+  base::test::ios::SpinRunLoopWithMinDelay(base::Seconds(1));
+
+  // Verify that the save password infobar does not appear.
+  [[EarlGrey
+      selectElementWithMatcher:
+          PasswordInfobarLabels(IDS_IOS_PASSWORD_MANAGER_SAVE_PASSWORD_PROMPT)]
+      assertWithMatcher:grey_notVisible()];
+
+  // Verify no credentials were stored.
+  int credentialsCount = [PasswordManagerAppInterface storedCredentialsCount];
+  GREYAssertEqual(0, credentialsCount, @"Credentials should not be stored.");
+}
+
 // Tests that update password prompt is shown on submitting the new password
 // for an already stored login.
 - (void)testUpdatePromptAppearsOnFormSubmission {
diff --git a/ios/chrome/browser/passwords/model/password_controller_js_unittest.mm b/ios/chrome/browser/passwords/model/password_controller_js_unittest.mm
index 843aa1e..10d79d9 100644
--- a/ios/chrome/browser/passwords/model/password_controller_js_unittest.mm
+++ b/ios/chrome/browser/passwords/model/password_controller_js_unittest.mm
@@ -848,75 +848,6 @@
   EXPECT_EQ(*expected_result_json, *result_json);
 }
 
-// Checks that a touchend event from a button which contains in a password form
-// works as a submission indicator for this password form.
-TEST_F(PasswordControllerJsTest, TouchendAsSubmissionIndicator) {
-  TestPasswordFormHelperDelegate* delegate =
-      [[TestPasswordFormHelperDelegate alloc] init];
-
-  PasswordFormHelper* helper =
-      [[PasswordFormHelper alloc] initWithWebState:web_state()];
-  helper.delegate = delegate;
-
-  web::test::LoadHtml(@"<html><body>"
-                       "<form name='login_form' id='login_form'>"
-                       "  Name: <input type='text' name='username'>"
-                       "  Password: <input type='password' name='password'>"
-                       "  <button id='submit_button' value='Submit'>"
-                       "</form>"
-                       "</body></html>",
-                      web_state());
-  ASSERT_TRUE(SetUpUniqueIDs());
-
-  // Call __gCrWeb.getRegisteredApi('passwords').getFunction('findPasswordForms')
-  // in order to set an event handler on the button touchend event.
-  FindPasswordFormsInFrame(GetMainWebFrame());
-
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/components/password_manager/ios/password_form_helper_unittest.mm b/components/password_manager/ios/password_form_helper_unittest.mm
index 4f46daf3..e3871520 100644
--- a/components/password_manager/ios/password_form_helper_unittest.mm
+++ b/components/password_manager/ios/password_form_helper_unittest.mm
@@ -1067,6 +1067,28 @@
   EXPECT_OCMOCK_VERIFY(delegate);
 }
 
+// Tests that the form submit message isn't handled when user interaction is
+// absent.
+TEST_F(PasswordFormHelperTest, HandleFormSubmittedMessage_NoUserInteraction) {
+  id delegate = OCMStrictProtocolMock(@protocol(PasswordFormHelperDelegate));
+  helper_.delegate = delegate;
+
+  LoadHtml(@"<p>");
+
+  web::ScriptMessage submit_message(
+      ValidFormSubmittedMessageBody(GetMainFrame()->GetFrameId()),
+      /*is_user_interacting=*/false,
+      /*is_main_frame=*/true,
+      /*request_url=*/std::nullopt, url::Origin());
+
+  HandleSubmittedFormStatus status =
+      [helper_ handleFormSubmittedMessage:submit_message];
+
+  EXPECT_EQ(HandleSubmittedFormStatus::kRejectedNoUserInteraction, status);
+
+  EXPECT_OCMOCK_VERIFY(delegate);
+}
+
 }  // namespace
 
 NS_ASSUME_NONNULL_END
diff --git a/ios/chrome/browser/passwords/model/password_controller_js_unittest.mm b/ios/chrome/browser/passwords/model/password_controller_js_unittest.mm
index 843aa1e..10d79d9 100644
--- a/ios/chrome/browser/passwords/model/password_controller_js_unittest.mm
+++ b/ios/chrome/browser/passwords/model/password_controller_js_unittest.mm
@@ -848,75 +848,6 @@
   EXPECT_EQ(*expected_result_json, *result_json);
 }
 
-// Checks that a touchend event from a button which contains in a password form
-// works as a submission indicator for this password form.
-TEST_F(PasswordControllerJsTest, TouchendAsSubmissionIndicator) {
-  TestPasswordFormHelperDelegate* delegate =
-      [[TestPasswordFormHelperDelegate alloc] init];
-
-  PasswordFormHelper* helper =
-      [[PasswordFormHelper alloc] initWithWebState:web_state()];
-  helper.delegate = delegate;
-
-  web::test::LoadHtml(@"<html><body>"
-                       "<form name='login_form' id='login_form'>"
-                       "  Name: <input type='text' name='username'>"
-                       "  Password: <input type='password' name='password'>"
-                       "  <button id='submit_button' value='Submit'>"
-                       "</form>"
-                       "</body></html>",
-                      web_state());
-  ASSERT_TRUE(SetUpUniqueIDs());
-
-  // Call __gCrWeb.getRegisteredApi('passwords').getFunction('findPasswordForms')
-  // in order to set an event handler on the button touchend event.
-  FindPasswordFormsInFrame(GetMainWebFrame());
-
-  // Simulate touchend event on the button.
-  ExecuteJavaScript(
-      @"document.getElementsByName('username')[0].value = 'user1';"
-       "document.getElementsByName('password')[0].value = 'password1';"
-       "var e = new UIEvent('touchend');"
-       "document.getElementsByTagName('button')[0].dispatchEvent(e);");
-
-  // Check that there was only 1 call for sendWebKitMessage.
-  ASSERT_EQ(1, delegate.submittedFormMessageCalls);
-
-  auto expected_form = base::DictValue()
-                           .Set("name", "login_form")
-                           .Set("origin", BaseUrl())
-                           .Set("action", BaseUrl())
-                           .Set("name_attribute", "login_form")
-                           .Set("id_attribute", "login_form")
-                           .Set("renderer_id", "1")
-                           .Set("host_frame", GetMainWebFrame()->GetFrameId());
-  base::DictValue expected_username_field = ParsedField(
-      /*renderer_id=*/"2", /*contole_type=*/"text",
-      /*identifier=*/"username", /*value=*/"user1",
-      /*label=*/"Name:", /*name=*/"username");
-
-  base::DictValue expected_password_field = ParsedField(
-      /*renderer_id=*/"3", /*contole_type=*/"password",
-      /*identifier=*/"password", /*value=*/"password1",
-      /*label=*/"Password:", /*name=*/"password");
-  auto expected_fields = base::ListValue()
-                             .Append(std::move(expected_username_field))
-                             .Append(std::move(expected_password_field));
-  expected_form.Set("fields", std::move(expected_fields));
-
-  autofill::FieldDataManager* fieldDataManager =
-      autofill::FieldDataManagerFactoryIOS::FromWebFrame(
-          delegate.lastSubmittedFormFrame);
-
-  base::expected<autofill::FormData, autofill::ExtractFormDataFailure>
-      expected_form_data = autofill::ExtractFormData(
-          expected_form, /*form_name_filter=*/std::nullopt, GURL(BaseUrl()),
-          url::Origin::Create(GURL(base::SysNSStringToUTF8(FormOrigin()))),
-          GetMainWebFrame()->GetUrl(), *fieldDataManager,
-          GetMainWebFrame()->GetFrameId());
-  EXPECT_THAT(expected_form_data, ValueIs(delegate.lastSubmittedForm));
-}
-
 // Check that a form is filled if url of a page and url in form fill data are
 // different only in paths.
 TEST_F(PasswordControllerJsTest, OriginsAreDifferentInPaths) {
diff --git a/ios/chrome/browser/passwords/model/password_controller_unittest.mm b/ios/chrome/browser/passwords/model/password_controller_unittest.mm
index 4cd79aef..dc2c582 100644
--- a/ios/chrome/browser/passwords/model/password_controller_unittest.mm
+++ b/ios/chrome/browser/passwords/model/password_controller_unittest.mm
@@ -1409,65 +1409,8 @@
   }));
 }
 
-// Tests that a touchend event from a button which contains in a password form
-// works as a submission indicator for this password form.
-TEST_F(PasswordControllerTest, TouchendAsSubmissionIndicator) {
-  ON_CALL(*store_, GetLogins)
-      .WillByDefault(WithArg<1>(InvokeEmptyConsumerWithForms(store_.get())));
-  const auto kHtml = std::to_array<std::string_view>(
-      {"<html><body>"
-       "<form name='login_form' id='login_form'>"
-       "  <input type='text' name='username'>"
-       "  <input type='password' name='password'>"
-       "  <button id='submit_button' value='Submit'>"
-       "</form>"
-       "</body></html>",
-       "<html><body>"
-       "<form name='login_form' id='login_form'>"
-       "  <input type='text' name='username'>"
-       "  <input type='password' name='password'>"
-       "  <button id='back' value='Back'>"
-       "  <button id='submit_button' type='submit' value='Submit'>"
-       "</form>"
-       "</body></html>"});
-
-  for (const std::string_view html : kHtml) {
-    LoadHtml(SysUTF8ToNSString(html));
-    WaitForFormManagersCreation();
-
-    std::unique_ptr<PasswordFormManagerForUI> form_manager_to_save;
-    EXPECT_CALL(*weak_client_, PromptUserToSaveOrUpdatePassword)
-        .WillOnce(MoveArgAndReturn<0>(&form_manager_to_save, true));
-
-    ExecuteJavaScript(
-        @"document.getElementsByName('username')[0].value = 'user1';"
-         "document.getElementsByName('password')[0].value = 'password1';"
-         "var e = new UIEvent('touchend');"
-         "document.getElementById('submit_button').dispatchEvent(e);");
-    LoadHtmlWithRendererInitiatedNavigation(
-        @"<html><body>Success</body></html>");
-
-    auto& form_manager_check = form_manager_to_save;
-    ASSERT_TRUE(WaitUntilConditionOrTimeout(kWaitForActionTimeout, ^bool() {
-      return form_manager_check != nullptr;
-    }));
-
-    EXPECT_EQ("https://chromium.test/",
-              form_manager_to_save->GetPendingCredentials().signon_realm);
-    EXPECT_EQ(u"user1",
-              form_manager_to_save->GetPendingCredentials().username_value);
-    EXPECT_EQ(u"password1",
-              form_manager_to_save->GetPendingCredentials().password_value);
-
-    auto* form_manager =
-        static_cast<PasswordFormManager*>(form_manager_to_save.get());
-    EXPECT_TRUE(form_manager->is_submitted());
-    EXPECT_FALSE(form_manager->IsPasswordUpdate());
-  }
-}
-
-// Tests that a touchend event from a button which contains in a password form
-// works as a submission indicator for this password form.
+// Tests that password credentials are correctly captured and prompted for
+// saving when a form is submitted from within a same-origin iframe.
 TEST_F(PasswordControllerTest, SavingFromSameOriginIframe) {
   ON_CALL(*store_, GetLogins)
       .WillByDefault(WithArg<1>(InvokeEmptyConsumerWithForms(store_.get())));
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.