Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactRace condition in Payments
DescriptionRace condition in Payments
ComponentPayments
Bug ClassRace
Tracker501643868
Fix commite117fd30d429 (chromium/src) +294/-147
CISA KEVNot listed
CreditedGoogle
Disclosed2026-09-08

Changed Functions

FunctionChangeNotes
if
components/autofill/core/browser/payments/iban_save_manager.cc
modified
switch
components/autofill/core/browser/payments/iban_save_manager.cc
modified

Files Changed

  • components/autofill/core/browser/payments/iban_save_manager.cc
From e117fd30d42942cd9243d8c78839840b201481c6 Mon Sep 17 00:00:00 2001
From: Luis Antunes <[email protected]>
Date: Tue, 04 Aug 2026 07:51:40 -0700
Subject: [PATCH] [autofill] Make IbanSaveManager stateless

Refactor IbanSaveManager to remove all mutable instance member variables
(such as context_token_ and upload_request_details_), making the class
completely stateless.

Key changes:
- Remove mutable instance fields from IbanSaveManager and update helper
  functions to accept candidate IBANs by value / const reference appropriately.
- Construct UploadIbanRequestDetails locally inside SendUploadRequest()
  rather than storing it as a class member.
- Pass request context (context_token, client_behavior_signals, candidate
  IBAN, and risk data) directly through callback closures.
- Synchronize concurrent risk data pre-loading and prompt deliberation
  using std::unique_ptr data containers bound to a completion closure and
  synchronized by base::BarrierClosure(2), ensuring network requests are
  sent only when user consent is granted.
- Update IbanSaveManagerTestApi with default parameters to simplify test
  call sites.
- Update TestPaymentsAutofillClient to store active prompt callbacks,
  enabling unit tests to simulate multiple overlapping prompts.
- Add unit test coverage in IbanSaveManagerTest verifying that overlapping
  save prompts preserve independent candidate IBANs and context tokens.

Fixed: 501643868
Change-Id: I4547eb6c2bf435b4ea095d98a82e3e7144a0f3da
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8087839
Reviewed-by: Xuehui Chen <[email protected]>
Reviewed-by: Darwin Yang <[email protected]>
Commit-Queue: Luis Antunes <[email protected]>
Reviewed-by: Slobodan Pejic <[email protected]>
Reviewed-by: Qihui Zhao <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1673337}
---

diff --git a/components/autofill/core/browser/payments/iban_save_manager.cc b/components/autofill/core/browser/payments/iban_save_manager.cc
index adadc2e..61d47ca 100644
--- a/components/autofill/core/browser/payments/iban_save_manager.cc
+++ b/components/autofill/core/browser/payments/iban_save_manager.cc
@@ -9,16 +9,19 @@
 #include <string>
 #include <string_view>
 #include <utility>
+#include <vector>
 
+#include "base/barrier_closure.h"
 #include "base/check_deref.h"
 #include "base/check_op.h"
 #include "base/feature_list.h"
-#include "base/functional/bind.h"
+#include "base/memory/weak_ptr.h"
 #include "base/strings/string_number_conversions.h"
 #include "base/strings/string_util.h"
 #include "base/strings/utf_string_conversions.h"
 #include "base/values.h"
 #include "build/buildflag.h"
+#include "components/autofill/core/browser/data_manager/payments/payments_data_manager.h"
 #include "components/autofill/core/browser/data_manager/personal_data_manager.h"
 #include "components/autofill/core/browser/data_model/payments/iban.h"
 #include "components/autofill/core/browser/foundations/autofill_client.h"
@@ -28,20 +31,35 @@
 #include "components/autofill/core/browser/payments/legal_message_line.h"
 #include "components/autofill/core/browser/payments/payments_autofill_client.h"
 #include "components/autofill/core/browser/payments/payments_network_interface.h"
+#include "components/autofill/core/browser/payments/payments_request_details.h"
 #include "components/autofill/core/browser/payments/payments_util.h"
 #include "components/autofill/core/browser/strike_databases/payments/iban_save_strike_database.h"
-#include "components/autofill/core/browser/studies/autofill_experiments.h"
 #include "components/autofill/core/common/autofill_payments_features.h"
 #include "components/autofill/core/common/autofill_regexes.h"
 #include "components/autofill/core/common/signatures.h"
 #include "components/strike_database/strike_database.h"
 #include "components/sync/base/data_type.h"
+#include "components/sync/service/sync_service.h"
 #include "components/sync/service/sync_user_settings.h"
 
 namespace autofill {
 
+namespace {
+
 using PaymentsRpcResult = payments::PaymentsAutofillClient::PaymentsRpcResult;
 
+// Applies `nickname` to `candidate` if `nickname` contains non-whitespace
+// characters.
+void ApplyNicknameIfPresent(Iban& candidate, std::u16string_view nickname) {
+  const std::u16string_view trimmed_nickname =
+      base::TrimWhitespace(nickname, base::TRIM_ALL);
+  if (!trimmed_nickname.empty()) {
+    candidate.set_nickname(std::u16string(trimmed_nickname));
+  }
+}
+
+}  // namespace
+
 IbanSaveManager::IbanSaveManager(AutofillClient* client)
     : client_(CHECK_DEREF(client)) {}
 
@@ -188,7 +206,7 @@
       });
 }
 
-bool IbanSaveManager::AttemptToOfferLocalSave(Iban& import_candidate) {
+bool IbanSaveManager::AttemptToOfferLocalSave(const Iban& import_candidate) {
   if (observer_for_testing_) {
     observer_for_testing_->OnOfferLocalSave();
   }
@@ -208,7 +226,7 @@
   return show_save_prompt;
 }
 
-bool IbanSaveManager::AttemptToOfferUploadSave(Iban& import_candidate) {
+bool IbanSaveManager::AttemptToOfferUploadSave(const Iban& import_candidate) {
   autofill_metrics::LogUploadIbanMetric(
       import_candidate.record_type() == Iban::kLocalIban
           ? autofill_metrics::UploadIbanOriginMetric::kLocalIban
@@ -216,25 +234,25 @@
       autofill_metrics::UploadIbanActionMetric::kOffered);
   bool show_save_prompt = !GetIbanSaveStrikeDatabase()->ShouldBlockFeature(
       GetPartialIbanHashString(base::UTF16ToUTF8(import_candidate.value())));
+  std::vector<ClientBehaviorConstants> client_behavior_signals;
 #if BUILDFLAG(IS_ANDROID)
-  upload_request_details_.client_behavior_signals.push_back(
+  client_behavior_signals.push_back(
       ClientBehaviorConstants::kShowAccountEmailInLegalMessage);
 #else
   if (base::FeatureList::IsEnabled(features::kAutofillEnableWalletBrandingV2)) {
-    upload_request_details_.client_behavior_signals.push_back(
+    client_behavior_signals.push_back(
         ClientBehaviorConstants::kShowAccountEmailInLegalMessage);
   }
 #endif
   client_->GetPaymentsAutofillClient()
       ->GetPaymentsNetworkInterface()
       ->GetIbanUploadDetails(
-          payments_data_manager().app_locale(),
-          upload_request_details_.client_behavior_signals,
+          payments_data_manager().app_locale(), client_behavior_signals,
           payments::GetBillingCustomerId(payments_data_manager()),
           import_candidate.GetCountryCode(),
           base::BindOnce(&IbanSaveManager::OnDidGetUploadDetails,
-                         weak_ptr_factory_.GetWeakPtr(), show_save_prompt,
-                         import_candidate));
+                         weak_ptr_factory_.GetWeakPtr(), import_candidate,
+                         client_behavior_signals, show_save_prompt));
   return show_save_prompt;
 }
 
@@ -250,11 +268,7 @@
     Iban import_candidate,
     payments::PaymentsAutofillClient::SaveIbanOfferUserDecision user_decision,
     std::u16string_view nickname) {
-  const std::u16string_view trimmed_nickname =
-      base::TrimWhitespace(nickname, base::TRIM_ALL);
-  if (!trimmed_nickname.empty()) {
-    import_candidate.set_nickname(std::u16string(trimmed_nickname));
-  }
+  ApplyNicknameIfPresent(import_candidate, nickname);
 
   const std::string& partial_iban_hash =
       GetPartialIbanHashString(base::UTF16ToUTF8(import_candidate.value()));
@@ -284,30 +298,21 @@
   }
 }
 
-void IbanSaveManager::OnUserDidDecideOnUploadSave(
+std::unique_ptr<Iban> IbanSaveManager::OnUserDidDecideOnUploadSave(
     Iban import_candidate,
-    bool show_save_prompt,
     payments::PaymentsAutofillClient::SaveIbanOfferUserDecision user_decision,
     std::u16string_view nickname) {
-  CHECK_NE(import_candidate.record_type(), Iban::kServerIban);
-  const std::u16string_view trimmed_nickname =
-      base::TrimWhitespace(nickname, base::TRIM_ALL);
-  if (!trimmed_nickname.empty()) {
-    import_candidate.set_nickname(std::u16string(trimmed_nickname));
-  }
-
+  const Iban::RecordType record_type = import_candidate.record_type();
+  CHECK_NE(record_type, Iban::kServerIban);
+  ApplyNicknameIfPresent(import_candidate, nickname);
   autofill_metrics::UploadIbanActionMetric action_metric;
+  std::unique_ptr<Iban> accepted_candidate;
   switch (user_decision) {
     case payments::PaymentsAutofillClient::SaveIbanOfferUserDecision::kAccepted:
       action_metric = autofill_metrics::UploadIbanActionMetric::kAccepted;
       autofill_metrics::LogIbanSaveAcceptedCountry(
           import_candidate.GetCountryCode());
-      user_did_accept_upload_prompt_ = true;
-      if (!upload_request_details_.risk_data.empty()) {
-        // Risk data has already been gathered, so the server request can be
-        // sent.
-        SendUploadRequest(import_candidate, show_save_prompt);
-      }
+      accepted_candidate = std::make_unique<Iban>(std::move(import_candidate));
       break;
     case payments::PaymentsAutofillClient::SaveIbanOfferUserDecision::kIgnored:
       action_metric = autofill_metrics::UploadIbanActionMetric::kIgnored;
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/components/autofill/core/browser/payments/iban_save_manager_unittest.cc b/components/autofill/core/browser/payments/iban_save_manager_unittest.cc
index 3681087..666c5cbe 100644
--- a/components/autofill/core/browser/payments/iban_save_manager_unittest.cc
+++ b/components/autofill/core/browser/payments/iban_save_manager_unittest.cc
@@ -7,6 +7,7 @@
 #include "base/json/json_reader.h"
 #include "base/strings/string_number_conversions.h"
 #include "base/strings/utf_string_conversions.h"
+#include "base/test/gmock_callback_support.h"
 #include "base/test/metrics/histogram_tester.h"
 #include "base/test/scoped_feature_list.h"
 #include "base/test/task_environment.h"
@@ -31,6 +32,10 @@
 namespace autofill {
 namespace {
 
+using ::testing::_;
+using ::testing::AllOf;
+using ::testing::Field;
+
 #if !BUILDFLAG(IS_ANDROID) && !BUILDFLAG(IS_IOS)
 
 constexpr char kLegalMessageLines[] =
@@ -543,7 +548,6 @@
 
   EXPECT_TRUE(test_api(GetIbanSaveManager()).AttemptToOfferUploadSave(iban));
   EXPECT_TRUE(autofill_client_.GetPaymentsAutofillClient()->risk_data_loaded());
-  EXPECT_TRUE(test_api(GetIbanSaveManager()).HasContextToken());
   EXPECT_TRUE(autofill_client_.GetPaymentsAutofillClient()
                   ->ConfirmUploadIbanToCloudWasCalled());
   EXPECT_FALSE(autofill_client_.GetPaymentsAutofillClient()
@@ -580,7 +584,6 @@
   SetUpGetIbanUploadDetailsResponse(/*is_successful=*/false);
 
   EXPECT_TRUE(test_api(GetIbanSaveManager()).AttemptToOfferUploadSave(iban));
-  EXPECT_FALSE(test_api(GetIbanSaveManager()).HasContextToken());
   EXPECT_FALSE(autofill_client_.GetPaymentsAutofillClient()
                    ->ConfirmUploadIbanToCloudWasCalled());
   EXPECT_TRUE(autofill_client_.GetPaymentsAutofillClient()
@@ -601,7 +604,6 @@
                                     /*includes_invalid_legal_message=*/true);
 
   EXPECT_TRUE(test_api(GetIbanSaveManager()).AttemptToOfferUploadSave(iban));
-  EXPECT_FALSE(test_api(GetIbanSaveManager()).HasContextToken());
   EXPECT_FALSE(autofill_client_.GetPaymentsAutofillClient()
                    ->ConfirmUploadIbanToCloudWasCalled());
   EXPECT_TRUE(autofill_client_.GetPaymentsAutofillClient()
@@ -620,7 +622,6 @@
 
   EXPECT_TRUE(
       test_api(GetIbanSaveManager()).AttemptToOfferUploadSave(another_iban));
-  EXPECT_TRUE(test_api(GetIbanSaveManager()).HasContextToken());
   EXPECT_TRUE(autofill_client_.GetPaymentsAutofillClient()
                   ->ConfirmUploadIbanToCloudWasCalled());
   EXPECT_FALSE(autofill_client_.GetPaymentsAutofillClient()
@@ -642,7 +643,6 @@
 
   EXPECT_TRUE(
       test_api(GetIbanSaveManager()).AttemptToOfferUploadSave(another_iban));
-  EXPECT_FALSE(test_api(GetIbanSaveManager()).HasContextToken());
   EXPECT_FALSE(autofill_client_.GetPaymentsAutofillClient()
                    ->ConfirmUploadIbanToCloudWasCalled());
   EXPECT_FALSE(autofill_client_.GetPaymentsAutofillClient()
@@ -665,10 +665,10 @@
   EXPECT_EQ(1, iban_save_strike_database.GetStrikes(partial_iban_hash));
   EXPECT_TRUE(test_api(GetIbanSaveManager()).AttemptToOfferUploadSave(iban));
 
-  test_api(GetIbanSaveManager())
-      .OnUserDidDecideOnUploadSave(iban, /*show_save_prompt=*/true,
-                                   SaveIbanOfferUserDecision::kAccepted,
-                                   u"My teacher's IBAN");
+  std::move(autofill_client_.GetPaymentsAutofillClient()
+                ->confirm_upload_iban_to_cloud_callbacks()
+                .back())
+      .Run(SaveIbanOfferUserDecision::kAccepted, u"My teacher's IBAN");
 
   // Verify the IBAN's strikes have been cleared.
   EXPECT_EQ(0, iban_save_strike_database.GetStrikes(partial_iban_hash));
@@ -690,10 +690,10 @@
 
   EXPECT_TRUE(test_api(GetIbanSaveManager()).AttemptToOfferUploadSave(iban));
 
-  test_api(GetIbanSaveManager())
-      .OnUserDidDecideOnUploadSave(iban, /*show_save_prompt=*/true,
-                                   SaveIbanOfferUserDecision::kAccepted,
-                                   u"My teacher's IBAN");
+  std::move(autofill_client_.GetPaymentsAutofillClient()
+                ->confirm_upload_iban_to_cloud_callbacks()
+                .back())
+      .Run(SaveIbanOfferUserDecision::kAccepted, u"My teacher's IBAN");
 
   // Verify the IBAN's strikes have been added by 1.
   EXPECT_EQ(2, iban_save_strike_database.GetStrikes(partial_iban_hash));
@@ -709,8 +709,7 @@
 
   IbanSaveStrikeDatabase iban_save_strike_database(strike_database_);
   test_api(GetIbanSaveManager())
-      .OnUserDidDecideOnUploadSave(iban, /*show_save_prompt=*/true,
-                                   SaveIbanOfferUserDecision::kDeclined);
+      .OnUserDidDecideOnUploadSave(iban, SaveIbanOfferUserDecision::kDeclined);
 
   // Verify the IBAN's strikes have been added by 1.
   EXPECT_EQ(1, iban_save_strike_database.GetStrikes(partial_iban_hash));
@@ -728,8 +727,7 @@
 
   IbanSaveStrikeDatabase iban_save_strike_database(strike_database_);
   test_api(GetIbanSaveManager())
-      .OnUserDidDecideOnUploadSave(iban, /*show_save_prompt=*/true,
-                                   SaveIbanOfferUserDecision::kIgnored);
+      .OnUserDidDecideOnUploadSave(iban, SaveIbanOfferUserDecision::kIgnored);
 
   // Verify the IBAN's strikes have been added by 1.
   EXPECT_EQ(1, iban_save_strike_database.GetStrikes(partial_iban_hash));
@@ -779,8 +777,7 @@
 
   ASSERT_TRUE(GetIbanSaveManager().AttemptToOfferSave(iban));
   test_api(GetIbanSaveManager())
-      .OnUserDidDecideOnUploadSave(iban, /*show_save_prompt=*/true,
-                                   SaveIbanOfferUserDecision::kAccepted,
+      .OnUserDidDecideOnUploadSave(iban, SaveIbanOfferUserDecision::kAccepted,
                                    u"My teacher's IBAN");
 
   histogram_tester.ExpectBucketCount(
@@ -802,8 +799,7 @@
 
   ASSERT_TRUE(GetIbanSaveManager().AttemptToOfferSave(iban));
   test_api(GetIbanSaveManager())
-      .OnUserDidDecideOnUploadSave(iban, /*show_save_prompt=*/true,
-                                   SaveIbanOfferUserDecision::kAccepted,
+      .OnUserDidDecideOnUploadSave(iban, SaveIbanOfferUserDecision::kAccepted,
                                    u"My teacher's IBAN");
 
   histogram_tester.ExpectBucketCount(
@@ -823,8 +819,7 @@
   EXPECT_TRUE(GetIbanSaveManager().AttemptToOfferSave(iban));
 
   test_api(GetIbanSaveManager())
-      .OnUserDidDecideOnUploadSave(iban, /*show_save_prompt=*/true,
-                                   SaveIbanOfferUserDecision::kDeclined,
+      .OnUserDidDecideOnUploadSave(iban, SaveIbanOfferUserDecision::kDeclined,
                                    u"My teacher's IBAN");
 
   histogram_tester.ExpectBucketCount(
@@ -845,8 +840,7 @@
 
   ASSERT_TRUE(GetIbanSaveManager().AttemptToOfferSave(iban));
   test_api(GetIbanSaveManager())
-      .OnUserDidDecideOnUploadSave(iban, /*show_save_prompt=*/true,
-                                   SaveIbanOfferUserDecision::kDeclined,
+      .OnUserDidDecideOnUploadSave(iban, SaveIbanOfferUserDecision::kDeclined,
                                    u"My teacher's IBAN");
 
   histogram_tester.ExpectBucketCount(
@@ -866,8 +860,7 @@
   EXPECT_TRUE(GetIbanSaveManager().AttemptToOfferSave(iban));
 
   test_api(GetIbanSaveManager())
-      .OnUserDidDecideOnUploadSave(iban, /*show_save_prompt=*/true,
-                                   SaveIbanOfferUserDecision::kIgnored,
+      .OnUserDidDecideOnUploadSave(iban, SaveIbanOfferUserDecision::kIgnored,
                                    u"My teacher's IBAN");
 
   histogram_tester.ExpectBucketCount(
@@ -888,8 +881,7 @@
 
   ASSERT_TRUE(GetIbanSaveManager().AttemptToOfferSave(iban));
   test_api(GetIbanSaveManager())
-      .OnUserDidDecideOnUploadSave(iban, /*show_save_prompt=*/true,
-                                   SaveIbanOfferUserDecision::kIgnored,
+      .OnUserDidDecideOnUploadSave(iban, SaveIbanOfferUserDecision::kIgnored,
                                    u"My teacher's IBAN");
 
   histogram_tester.ExpectBucketCount(
@@ -944,8 +936,7 @@
 
   EXPECT_TRUE(GetIbanSaveManager().AttemptToOfferSave(iban));
   test_api(GetIbanSaveManager())
-      .OnUserDidDecideOnUploadSave(iban, /*show_save_prompt=*/true,
-                                   SaveIbanOfferUserDecision::kAccepted,
+      .OnUserDidDecideOnUploadSave(iban, SaveIbanOfferUserDecision::kAccepted,
                                    u"IBAN nickname");
 
   histogram_tester.ExpectUniqueSample("Autofill.Iban.CountryOfSaveAcceptedIban",
@@ -961,7 +952,7 @@
   iban.set_value(std::u16string(test::kIbanValue16));
   ASSERT_TRUE(personal_data().payments_data_manager().GetLocalIbans().empty());
   test_api(GetIbanSaveManager())
-      .OnDidUploadIban(iban, /*show_save_prompt=*/true,
+      .OnDidUploadIban(std::make_unique<Iban>(iban), /*show_save_prompt=*/true,
                        payments::PaymentsAutofillClient::PaymentsRpcResult::
                            kPermanentFailure);
 
@@ -981,7 +972,7 @@
   personal_data().payments_data_manager().AddAsLocalIban(iban);
   ASSERT_EQ(personal_data().payments_data_manager().GetLocalIbans().size(), 1U);
   test_api(GetIbanSaveManager())
-      .OnDidUploadIban(iban, /*show_save_prompt=*/true,
+      .OnDidUploadIban(std::make_unique<Iban>(iban), /*show_save_prompt=*/true,
                        payments::PaymentsAutofillClient::PaymentsRpcResult::
                            kPermanentFailure);
 
@@ -1003,7 +994,7 @@
   ASSERT_EQ(personal_data().payments_data_manager().GetLocalIbans().size(), 1U);
   iban.set_nickname(u"new nickname");
   test_api(GetIbanSaveManager())
-      .OnDidUploadIban(iban, /*show_save_prompt=*/true,
+      .OnDidUploadIban(std::make_unique<Iban>(iban), /*show_save_prompt=*/true,
                        payments::PaymentsAutofillClient::PaymentsRpcResult::
                            kPermanentFailure);
 
@@ -1015,6 +1006,94 @@
       u"");
 }
 
+// Tests that overlapping upload flows preserve independent context tokens and
+// IBAN candidates.
+TEST_F(IbanSaveManagerTest, UploadSaveIban_OverlappingUploadFlows) {
+  Iban iban1;
+  iban1.set_value(std::u16string(test::kIbanValue16));
+  Iban iban2;
+  iban2.set_value(u"CH56 0483 5012 3456 7800 9");
+
+  EXPECT_CALL(*payments_network_interface(), GetIbanUploadDetails)
+      .WillOnce(base::test::RunOnceCallback<4>(
+          payments::PaymentsAutofillClient::PaymentsRpcResult::kSuccess,
+          kCapitalizedIbanRegex, u"token1",
+          std::make_unique<base::DictValue>(*base::JSONReader::ReadDict(
+              kLegalMessageLines, base::JSON_PARSE_CHROMIUM_EXTENSIONS))))
+      .WillOnce(base::test::RunOnceCallback<4>(
+          payments::PaymentsAutofillClient::PaymentsRpcResult::kSuccess,
+          kCapitalizedIbanRegex, u"token2",
+          std::make_unique<base::DictValue>(*base::JSONReader::ReadDict(
+              kLegalMessageLines, base::JSON_PARSE_CHROMIUM_EXTENSIONS))));
+
+  EXPECT_CALL(
+      *payments_network_interface(),
+      UploadIban(AllOf(Field(&payments::UploadIbanRequestDetails::value,
+                             iban1.value()),
+                       Field(&payments::UploadIbanRequestDetails::context_token,
+                             u"token1")),
+                 _))
+      .WillOnce(base::test::RunOnceCallback<1>(
+          payments::PaymentsAutofillClient::PaymentsRpcResult::kSuccess));
+
+  EXPECT_TRUE(test_api(GetIbanSaveManager()).AttemptToOfferUploadSave(iban1));
+  EXPECT_TRUE(test_api(GetIbanSaveManager()).AttemptToOfferUploadSave(iban2));
+  ASSERT_EQ(autofill_client_.GetPaymentsAutofillClient()
+                ->confirm_upload_iban_to_cloud_callbacks()
+                .size(),
+            2U);
+
+  std::move(autofill_client_.GetPaymentsAutofillClient()
+                ->confirm_upload_iban_to_cloud_callbacks()[0])
+      .Run(payments::PaymentsAutofillClient::SaveIbanOfferUserDecision::
+               kAccepted,
+           u"My IBAN");
+}
+
+// Tests that when the user accepts the upload save prompt BEFORE risk data
+// finishes loading, the upload request is sent cleanly once risk data arrives.
+TEST_F(IbanSaveManagerTest, UploadSaveIban_UserAcceptsBeforeRiskDataReady) {
+  Iban iban;
+  iban.set_value(std::u16string(test::kIbanValue16));
+  SetUpGetIbanUploadDetailsResponse(/*is_successful=*/true);
+
+  autofill_client_.GetPaymentsAutofillClient()
+      ->set_defer_load_risk_data_responses(true);
+
+  EXPECT_CALL(
+      *payments_network_interface(),
+      UploadIban(
+          AllOf(Field(&payments::UploadIbanRequestDetails::value, iban.value()),
+                Field(&payments::UploadIbanRequestDetails::risk_data,
+                      "delayed risk data")),
+          _))
+      .WillOnce(base::test::RunOnceCallback<1>(
+          payments::PaymentsAutofillClient::PaymentsRpcResult::kSuccess));
+
+  EXPECT_TRUE(test_api(GetIbanSaveManager()).AttemptToOfferUploadSave(iban));
+
+  ASSERT_EQ(autofill_client_.GetPaymentsAutofillClient()
+                ->confirm_upload_iban_to_cloud_callbacks()
+                .size(),
+            1U);
+  ASSERT_EQ(autofill_client_.GetPaymentsAutofillClient()
+                ->load_risk_data_callbacks()
+                .size(),
+            1U);
+
+  // 1. User accepts prompt FIRST (before risk data is ready).
+  std::move(autofill_client_.GetPaymentsAutofillClient()
+                ->confirm_upload_iban_to_cloud_callbacks()[0])
+      .Run(payments::PaymentsAutofillClient::SaveIbanOfferUserDecision::
+               kAccepted,
+           u"My IBAN");
+
+  // 2. Risk data finishes loading SECOND.
+  std::move(autofill_client_.GetPaymentsAutofillClient()
+                ->load_risk_data_callbacks()[0])
+      .Run("delayed risk data");
... (truncated)
Loading diff…

Original Bug Report

reported by [email protected]

State confusion in IbanSaveManager allows arbitrary IBAN upload without consent

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 without the Chrome Security team.

Overview: A state confusion vulnerability in IbanSaveManager allows a malicious webpage to swap the IBAN being saved to a user’s Google Payments wallet. By overlapping two IBAN upload flows, an attacker can overwrite shared state, causing the user’s ‘Save’ click on a legitimate prompt to unknowingly upload an attacker-controlled IBAN.

Affected files:

  • components/autofill/core/browser/payments/iban_save_manager.cc
  • components/autofill/core/browser/payments/iban_save_manager.h
  • chrome/browser/ui/autofill/payments/iban_bubble_controller_impl.cc

Estimated timestamp from git blame: 2025-09-03

Summary

A state confusion vulnerability exists in IbanSaveManager where multiple concurrent IBAN upload flows overwrite shared instance-level state. A malicious webpage can exploit this by presenting a legitimate “decoy” IBAN prompt to the user, while asynchronously starting a hidden upload flow for an attacker-controlled IBAN. When the user interacts with the decoy prompt, the overwritten state causes the browser to upload the attacker’s IBAN to the user’s Google Payments wallet without their knowledge or consent.

Vulnerability Details

IbanSaveManager coordinates the asynchronous process of obtaining upload details, displaying a consent bubble, and collecting client risk data. The state for this process—including upload_request_details_, context_token_, and user_did_accept_upload_prompt_—is stored directly in the IbanSaveManager instance. Because the client is per-WebContents, this state is shared across all frames and all concurrent save flows.

An attacker can exploit this shared state using the following sequence:

  1. The attacker triggers a form submission for a “decoy” IBAN.
  2. IbanSaveManager fetches upload details, clears the shared state, and sets the decoy context_token_.
  3. The UI successfully displays the “Save IBAN” bubble for the decoy IBAN.
  4. IbanSaveManager initiates an asynchronous LoadRiskData call for the decoy IBAN.
  5. While the user views the bubble, the attacker’s script submits a hidden form containing an attacker-controlled IBAN.
  6. IbanSaveManager fetches upload details for the attacker IBAN. This overwrites the shared state: upload_request_details_.risk_data is cleared, user_did_accept_upload_prompt_ is reset to false, and context_token_ is set to the attacker’s token.
  7. IbanBubbleControllerImpl::OfferUploadSave is called for the attacker IBAN. Because the decoy bubble is already visible, it early-returns (iban_bubble_controller_impl.cc:75), suppressing the attacker prompt but leaving the attacker’s state active in IbanSaveManager.
  8. IbanSaveManager initiates an asynchronous LoadRiskData call for the attacker IBAN.
  9. The user clicks “Save” on the visible decoy bubble. The callback sets user_did_accept_upload_prompt_ = true. However, because the attacker’s flow cleared the risk_data in step 6, the upload is paused pending risk data.
  10. The asynchronous LoadRiskData for the attacker IBAN completes, invoking OnDidGetUploadRiskData with the attacker’s IBAN as the parameter.
  11. Because user_did_accept_upload_prompt_ is now true, it immediately calls SendUploadRequest(attacker_iban).
  12. SendUploadRequest builds the final request using the attacker’s IBAN and the shared context_token_ (which is the attacker’s token from step 6). The attacker’s IBAN is successfully saved to the user’s account.

Note: The timing of LoadRiskData can be manipulated by the attacker by artificially stalling the main thread with heavy JavaScript or WebGL operations, ensuring the attacker’s risk data callback fires after the user clicks “Save”.

Impact

An attacker can persist an arbitrary IBAN to a user’s synced Google account without their consent. This saved IBAN will be offered as an autofill suggestion across the user’s devices for future SEPA payment forms, creating a direct vector for misdirected financial transfers.

Potential Reproduction Steps

Note: Our tooling agent cannot execute code; these are suggested steps based on source code analysis.

  1. Serve a page with a visible form containing a legitimate “decoy” IBAN and a hidden iframe containing an “attacker” IBAN form.
  2. Trigger a click on the decoy form’s submit button. The “Save IBAN” bubble appears.
  3. Use JavaScript to temporarily block the main thread to delay LoadRiskData callbacks.
  4. Submit the hidden attacker form.
  5. Unblock the thread.
  6. As a user, click the “Save” button on the visible decoy bubble.
  7. Observe via chrome://settings/payments that the attacker’s IBAN has been added to the user’s wallet, not the decoy.

Suggested Fix

Remove the instance-level state (context_token_, upload_request_details_, user_did_accept_upload_prompt_) from IbanSaveManager. Instead, encapsulate the state for each upload flow into a distinct struct or object (e.g., UploadIbanRequestContext) that is passed along with the callbacks, or ensure that overlapping requests explicitly cancel and reset the UI rather than silently corrupting the underlying state.

Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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