CVE-2026-79089
Overview
Background
- Keyboard accessory
- the Android autofill surface (
AutofillKeyboardAccessoryControllerImpl) that shows autofill suggestions in a bar above the soft keyboard and lets the user long-press an entry to delete it. - `suggestions_` / `labels_`
- parallel vectors held by the controller that store the currently shown
Suggestionobjects and their labels; they are replaced whenever new suggestions are shown for the field. - Deletion confirmation dialog
- an asynchronous dialog opened via the view’s
ConfirmDeletion, whosebase::OnceCallback<void(bool)>is bound toOnDeletionDialogClosedand runs only after the user answers, by which timesuggestions_may have changed. - Stale index
- an integer position captured at one time and used later against a container whose contents may have been replaced, so it may now name a different element.
Root Cause Analysis
The advisory labels this a race condition in “Transactions Platform”, but the fixing commit is entirely in the Android autofill keyboard accessory controller (chrome/browser/ui/autofill/autofill_keyboard_accessory_controller_impl.cc). The race is between the asynchronous deletion dialog and a suggestions refresh. Before the patch, RemoveSuggestion bound the integer index into the OnDeletionDialogClosed callback. When the callback ran, it only checked index < suggestions_.size() (the removed comment says this outright and carries a TODO(crbug.com/40766704) to “Replace these checks with a stronger identifier”). If the suggestions were replaced while the dialog was open, the stale index could still be in bounds but point at a different suggestion. The controller would then pass suggestions_[index] to delegate_->RemoveSuggestion, look up the address profile from that element’s payload, and erase that element from suggestions_ and labels_. So the entry deleted was not the one the user confirmed.
The fix binds a copy of the Suggestion itself (suggestions_[index]) into the callback. At close time it re-locates that suggestion with std::ranges::find, does nothing if it is gone, and computes the erase position from the found iterator. It also adds an up-front bounds check in RemoveSuggestion before indexing suggestions_.
Suggestion itself) and re-validates that it is still present before deleting anything.Attack Path
- Open deletion dialog
On Android, the user long-presses a keyboard-accessory suggestion, so
RemoveSuggestion(index, kKeyboardAccessory)opens a confirmation dialog whose callback capturesindex. - Suggestions refreshed while pending
While the dialog is open, a new set of suggestions is shown for the field, which replaces
suggestions_andlabels_(the regression test shows{s1, s2}becoming{s2, s3}). Whether page script can deliberately force this refresh at the right moment is (inferred). - User confirms
OnDeletionDialogClosed(index, true)runs; the staleindexpasses the in-bounds check but now refers to a different suggestion. - Wrong entry removed
delegate_->RemoveSuggestionand the profile lookup operate on the wrongSuggestion, so an autofill or autocomplete entry the user never chose to delete is removed from the user’s stored data.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/ui/autofill/autofill_keyboard_accessory_controller_impl.cc |
modified | |
switchchrome/browser/ui/autofill/autofill_keyboard_accessory_controller_impl.cc |
modified |
Files Changed
chrome/browser/ui/autofill/autofill_keyboard_accessory_controller_impl.hchrome/browser/ui/autofill/autofill_keyboard_accessory_controller_impl_unittest.ccchrome/browser/ui/autofill/autofill_keyboard_accessory_controller_impl.cc
Audit Directions
- Index bound into async callbacksSearch autofill/password popup controllers (desktop popup, Android bottom sheets,
TODO(crbug.com/40766704)-style comments) forBindOnce(..., index)where the callback later indexes a container that can be refreshed in the meantime. - Bounds check as staleness checkFlag code where a
size()comparison is the only guard after an asynchronous gap. In-bounds does not mean the element is the same one, so prefer stable identifiers (GUIDs, value equality, or a generation counter). - Parallel container mutationCheck other places that erase from
suggestions_andlabels_(or similar parallel vectors) to confirm both are updated from the same validated position and thatCHECK_EQsize invariants still hold after a concurrent refresh.
Patch
From 83f649e5bfece81f7b064c4924ba5febc9cffd4c Mon Sep 17 00:00:00 2001 From: Ireneusz Szulc <[email protected]> Date: Fri, 03 Jul 2026 08:30:06 -0700 Subject: [PATCH] Autofill: Invalidate pending deletion callbacks on suggestion updates Prevent stale index deletion in AutofillKeyboardAccessoryControllerImpl when the suggestions list updates while a deletion confirmation dialog is open. Bug: 513792983 Change-Id: If5aa50a3a08f73f0fd4ec25ab38a5e596a6a6964 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8032761 Commit-Queue: Ireneusz Szulc <[email protected]> Reviewed-by: Christoph Schwering <[email protected]> Cr-Commit-Position: refs/heads/main@{#1656594} --- diff --git a/chrome/browser/ui/autofill/autofill_keyboard_accessory_controller_impl.cc b/chrome/browser/ui/autofill/autofill_keyboard_accessory_controller_impl.cc index 8df1879..859805c 100644 --- a/chrome/browser/ui/autofill/autofill_keyboard_accessory_controller_impl.cc +++ b/chrome/browser/ui/autofill/autofill_keyboard_accessory_controller_impl.cc @@ -459,6 +459,9 @@ AutofillMetrics::SingleEntryRemovalMethod removal_method) { CHECK_EQ(removal_method, AutofillMetrics::SingleEntryRemovalMethod::kKeyboardAccessory); + if (base::checked_cast<size_t>(index) >= suggestions_.size()) { + return false; + } RemovalConfirmationText removal_text; if (!GetRemovalConfirmationText(index, &removal_text)) { return false; @@ -469,30 +472,28 @@ removal_text.confirm_button_text, base::BindOnce( &AutofillKeyboardAccessoryControllerImpl::OnDeletionDialogClosed, - GetWeakPtr(), index)); + GetWeakPtr(), suggestions_[index])); return true; } void AutofillKeyboardAccessoryControllerImpl::OnDeletionDialogClosed( - int index, + const Suggestion& suggestion, bool confirmed) { - // This function might be called in a callback, so ensure the list index is - // still in bounds. If not, terminate the removing and consider it failed. - // TODO(crbug.com/40766704): Replace these checks with a stronger identifier. - if (base::checked_cast<size_t>(index) >= suggestions_.size()) { + auto it = std::ranges::find(suggestions_, suggestion); + if (it == suggestions_.end()) { return; } CHECK_EQ(suggestions_.size(), labels_.size()); const FillingProduct filling_product = - GetFillingProductFromSuggestionType(GetSuggestionAt(index).type); + GetFillingProductFromSuggestionType(suggestion.type); if (filling_product == FillingProduct::kAddress && web_contents_) { PersonalDataManager* pdm = PersonalDataManagerFactory::GetForBrowserContext( web_contents_->GetBrowserContext()); - const auto* payload = std::get_if<Suggestion::AutofillProfilePayload>( - &GetSuggestionAt(index).payload); + const auto* payload = + std::get_if<Suggestion::AutofillProfilePayload>(&suggestion.payload); if (pdm && payload) { const AutofillProfile* profile = pdm->address_data_manager().GetProfileByGUID(payload->guid.value()); @@ -507,7 +508,7 @@ return; } - if (!delegate_->RemoveSuggestion(suggestions_[index])) { + if (!delegate_->RemoveSuggestion(suggestion)) { return; } switch (filling_product) { @@ -538,7 +539,8 @@ } // Remove the deleted element. - suggestions_.erase(suggestions_.begin() + index); + const size_t index = std::distance(suggestions_.begin(), it); + suggestions_.erase(it); labels_.erase(labels_.begin() + index); if (HasSuggestions()) { diff --git a/chrome/browser/ui/autofill/autofill_keyboard_accessory_controller_impl.h b/chrome/browser/ui/autofill/autofill_keyboard_accessory_controller_impl.h index ac604a8e..d246fac 100644 --- a/chrome/browser/ui/autofill/autofill_keyboard_accessory_controller_impl.h +++ b/chrome/browser/ui/autofill/autofill_keyboard_accessory_controller_impl.h @@ -111,10 +111,9 @@ // of `suggestions_`. void OrderSuggestionsAndCreateLabels(); - // Reacts to the result of a deletion dialog by attempting to delete the - // suggestion at `index` if the dialog `confirmed` deletion and by emitting - // metrics. - void OnDeletionDialogClosed(int index, bool confirmed); + // Reacts to the result of a deletion dialog by attempting to delete + // `suggestion` if the dialog `confirmed` deletion and by emitting metrics. + void OnDeletionDialogClosed(const Suggestion& suggestion, bool confirmed); // Hides the view and asynchronously deletes itself. void HideViewAndDie(); diff --git a/chrome/browser/ui/autofill/autofill_keyboard_accessory_controller_impl_unittest.cc b/chrome/browser/ui/autofill/autofill_keyboard_accessory_controller_impl_unittest.cc index ffd4d78..db7b0ad6 100644 --- a/chrome/browser/ui/autofill/autofill_keyboard_accessory_controller_impl_unittest.cc +++ b/chrome/browser/ui/autofill/autofill_keyboard_accessory_controller_impl_unittest.cc @@ -403,6 +403,46 @@ AutofillMetrics::SingleEntryRemovalMethod::kKeyboardAccessory)); } +// Tests that if suggestions are updated while a deletion confirmation dialog is +// open, confirming the deletion of the old suggestion does not result in +// deleting a wrong suggestion at a stale index. +TEST_F(AutofillKeyboardAccessoryControllerImplTest, + RemoveSuggestion_StaleIndexDeletesWrongSuggestion) { + const auto suggestion1 = + Suggestion(u"Autocomplete entry 1", SuggestionType::kAutocompleteEntry); + const auto suggestion2 = + Suggestion(u"Autocomplete entry 2", SuggestionType::kAutocompleteEntry); + + ShowSuggestions(manager(), {suggestion1, suggestion2}); + ASSERT_TRUE(client().popup_view()); + + base::OnceCallback<void(bool)> captured_deletion_callback; + EXPECT_CALL(*client().popup_view(), ConfirmDeletion) + .WillOnce([&](const std::u16string& title, const std::u16string& body, + const std::u16string& body_link, + const std::u16string& confirm_button_text, + base::OnceCallback<void(bool)> deletion_callback) { + captured_deletion_callback = std::move(deletion_callback); + }); + + // User long-presses suggestion at index 0 + EXPECT_TRUE(client().suggestion_controller(manager()).RemoveSuggestion( + /*index=*/0, + AutofillMetrics::SingleEntryRemovalMethod::kKeyboardAccessory)); + ASSERT_FALSE(captured_deletion_callback.is_null()); + + // While dialog is pending, suggestions list changes + const auto suggestion3 = + Suggestion(u"Autocomplete entry 3", SuggestionType::kAutocompleteEntry); + ShowSuggestions(manager(), {suggestion2, suggestion3}); + + // When user confirms deletion dialog, suggestion1 is no longer in + // suggestions_, so RemoveSuggestion is NEVER called. + EXPECT_CALL(manager().external_delegate(), RemoveSuggestion).Times(0); + + std::move(captured_deletion_callback).Run(/*confirmed=*/true); +} + // When a suggestion is accepted, the popup is hidden inside // `delegate->DidAcceptSuggestion()`. On Android, some code is still being // executed after hiding. This test makes sure no use-after-free, null pointer
Regression Test / PoC
diff --git a/chrome/browser/ui/autofill/autofill_keyboard_accessory_controller_impl_unittest.cc b/chrome/browser/ui/autofill/autofill_keyboard_accessory_controller_impl_unittest.cc
index ffd4d78..db7b0ad6 100644
--- a/chrome/browser/ui/autofill/autofill_keyboard_accessory_controller_impl_unittest.cc
+++ b/chrome/browser/ui/autofill/autofill_keyboard_accessory_controller_impl_unittest.cc
@@ -403,6 +403,46 @@
AutofillMetrics::SingleEntryRemovalMethod::kKeyboardAccessory));
}
+// Tests that if suggestions are updated while a deletion confirmation dialog is
+// open, confirming the deletion of the old suggestion does not result in
+// deleting a wrong suggestion at a stale index.
+TEST_F(AutofillKeyboardAccessoryControllerImplTest,
+ RemoveSuggestion_StaleIndexDeletesWrongSuggestion) {
+ const auto suggestion1 =
+ Suggestion(u"Autocomplete entry 1", SuggestionType::kAutocompleteEntry);
+ const auto suggestion2 =
+ Suggestion(u"Autocomplete entry 2", SuggestionType::kAutocompleteEntry);
+
+ ShowSuggestions(manager(), {suggestion1, suggestion2});
+ ASSERT_TRUE(client().popup_view());
+
+ base::OnceCallback<void(bool)> captured_deletion_callback;
+ EXPECT_CALL(*client().popup_view(), ConfirmDeletion)
+ .WillOnce([&](const std::u16string& title, const std::u16string& body,
+ const std::u16string& body_link,
+ const std::u16string& confirm_button_text,
+ base::OnceCallback<void(bool)> deletion_callback) {
+ captured_deletion_callback = std::move(deletion_callback);
+ });
+
+ // User long-presses suggestion at index 0
+ EXPECT_TRUE(client().suggestion_controller(manager()).RemoveSuggestion(
+ /*index=*/0,
+ AutofillMetrics::SingleEntryRemovalMethod::kKeyboardAccessory));
+ ASSERT_FALSE(captured_deletion_callback.is_null());
+
+ // While dialog is pending, suggestions list changes
+ const auto suggestion3 =
+ Suggestion(u"Autocomplete entry 3", SuggestionType::kAutocompleteEntry);
+ ShowSuggestions(manager(), {suggestion2, suggestion3});
+
+ // When user confirms deletion dialog, suggestion1 is no longer in
+ // suggestions_, so RemoveSuggestion is NEVER called.
+ EXPECT_CALL(manager().external_delegate(), RemoveSuggestion).Times(0);
+
+ std::move(captured_deletion_callback).Run(/*confirmed=*/true);
+}
+
// When a suggestion is accepted, the popup is hidden inside
// `delegate->DidAcceptSuggestion()`. On Android, some code is still being
// executed after hiding. This test makes sure no use-after-free, null pointer