Chrome · Chrome for iOS
CVE-2026-18003
Logic Error in Chrome for iOS
Overview
Low
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcomponents/password_manager/ios/password_form_helper.mm |
modified | |
TEST_Fcomponents/password_manager/ios/password_form_helper_unittest.mm |
modified | |
ifcomponents/password_manager/ios/resources/password_controller.ts |
modified | |
TEST_Fios/chrome/browser/passwords/model/password_controller_js_unittest.mm |
modified |
Files Changed
components/password_manager/ios/password_form_helper.hcomponents/password_manager/ios/password_form_helper.mmcomponents/password_manager/ios/password_form_helper_unittest.mmcomponents/password_manager/ios/resources/password_controller.tsios/chrome/browser/passwords/model/password_controller_egtest.mmios/chrome/browser/passwords/model/password_controller_js_unittest.mm
Patch
From 0c06681f1fa50bcd7dbf942d514fb9bce32c859c Mon Sep 17 00:00:00 2001 From: Alexis Hétu <[email protected]> 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 <[email protected]> Reviewed-by: Vincent Boisselle <[email protected]> Commit-Queue: Alexis Hétu <[email protected]> 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.
References
On This Page