Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in Payments
DescriptionInsufficient validation of untrusted input in Payments
ComponentPayments
Bug ClassLogic Error
Tracker498079379
Fix commitd06dc4817bab (chromium/src) +96/-0
CISA KEVNot listed
CreditedGoogle
Disclosed2026-07-29

Changed Functions

FunctionChangeNotes
if
components/payments/content/payment_request.cc
modified

Files Changed

  • components/payments/content/payment_request.cc
  • components/payments/content/payment_request_unittest.cc
  • components/payments/core/error_strings.cc
  • components/payments/core/error_strings.h
From d06dc4817bab6d2d2516cafe2afa8257f3ca7b4c Mon Sep 17 00:00:00 2001
From: Jochen Eisinger <jochen@chromium.org>
Date: Wed, 24 Jun 2026 12:12:23 -0700
Subject: [PATCH] [SPC] Reject updateWith() once the dialog has been shown

The Secure Payment Confirmation dialog reads the payment details once
when it is set up and does not observe later spec changes, so
updateWith() must not be allowed to mutate the details after that point.
The only legitimate updateWith() for SPC is the one that resolves the
promise passed into show(), which arrives while the spec is still in the
INITIAL_PAYMENT_DETAILS state and before the dialog has read anything.
Reject any later updateWith() as a bad message and tear down the
request.

Also extend PaymentRequestValidationTest with the bits needed to
exercise Init()/Show() end to end (TestPersonalDataManager,
IsBrowserWindowActive() mock, credential_ids in the SPC method data) and
add coverage for both the rejected and the show-promise paths.

TAG=agy

Fixed: 498079379
Change-Id: If2677fa62d24ed82f3de41cfbf6724f1a7432e6b
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7984525
Commit-Queue: Jochen Eisinger <jochen@chromium.org>
Reviewed-by: Slobodan Pejic <slobodan@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1651879}
---

diff --git a/components/payments/content/payment_request.cc b/components/payments/content/payment_request.cc
index 847c687..f1c3e5c 100644
--- a/components/payments/content/payment_request.cc
+++ b/components/payments/content/payment_request.cc
@@ -543,6 +543,18 @@
     return;
   }
 
+  // The "secure-payment-confirmation" dialog displays a snapshot of the
+  // payment details at the time it is shown and does not refresh, so the only
+  // permitted update is the resolution of the promise passed into show()
+  // before the dialog has displayed anything.
+  if (spec_->IsSecurePaymentConfirmationRequested() && spec_->IsInitialized()) {
+    log_.Error(errors::kSecurePaymentConfirmationUpdateWithNotAllowed);
+    mojo::ReportBadMessage(
+        errors::kSecurePaymentConfirmationUpdateWithNotAllowed);
+    ResetAndDeleteThis();
+    return;
+  }
+
   // ID cannot be updated. Updating the total is optional.
   if (!details || details->id) {
     log_.Error(errors::kInvalidPaymentDetails);
diff --git a/components/payments/content/payment_request_unittest.cc b/components/payments/content/payment_request_unittest.cc
index ab8298b..4465d72 100644
--- a/components/payments/content/payment_request_unittest.cc
+++ b/components/payments/content/payment_request_unittest.cc
@@ -10,6 +10,7 @@
 
 #include "base/test/bind.h"
 #include "base/test/scoped_feature_list.h"
+#include "components/autofill/core/browser/data_manager/test_personal_data_manager.h"
 #include "components/payments/content/mock_content_payment_request_delegate.h"
 #include "components/payments/content/payment_request_display_manager.h"
 #include "content/public/browser/render_frame_host.h"
@@ -39,6 +40,8 @@
       scoped_features_.InitAndDisableFeature(
           ::features::kSecurePaymentConfirmation);
     }
+    personal_data_manager_.test_address_data_manager()
+        .SetAutofillProfileEnabled(false);
     NavigateAndCommit(page_url_);
   }
 
@@ -56,6 +59,10 @@
         .WillByDefault(testing::ReturnRefOfCopy(std::string("en-US")));
     ON_CALL(*delegate, GetLastCommittedURL())
         .WillByDefault(testing::ReturnRef(page_url_));
+    ON_CALL(*delegate, GetPersonalDataManager())
+        .WillByDefault(testing::Return(&personal_data_manager_));
+    ON_CALL(*delegate, IsBrowserWindowActive())
+        .WillByDefault(testing::Return(true));
     return delegate;
   }
 
@@ -66,6 +73,8 @@
     if (IsSecurePaymentConfirmationEnabled()) {
       spc_method->secure_payment_confirmation =
           mojom::SecurePaymentConfirmationRequest::New();
+      spc_method->secure_payment_confirmation->credential_ids.push_back(
+          {1, 2, 3, 4});
       spc_method->secure_payment_confirmation->challenge = {1, 2, 3, 4};
       spc_method->secure_payment_confirmation->rp_id = "rp.id";
       spc_method->secure_payment_confirmation->instrument =
@@ -92,6 +101,7 @@
   }
 
   const GURL page_url_{"https://a.com"};
+  autofill::TestPersonalDataManager personal_data_manager_;
   PaymentRequestDisplayManager display_manager_;
   base::test::ScopedFeatureList scoped_features_;
 };
@@ -188,6 +198,75 @@
   }
 }
 
+// Tests that the payment details cannot be updated for a
+// "secure-payment-confirmation" request after it has been shown, because the
+// dialog displays a snapshot of the details and would not reflect changes made
+// while it was visible.
+TEST_P(PaymentRequestValidationTest,
+       SecurePaymentConfirmationRejectsUpdateWith) {
+  mojo::test::BadMessageObserver bad_message_observer;
+
+  mojo::Remote<mojom::PaymentRequest> payment_request;
+  new PaymentRequest(CreateMockDelegate(),
+                     payment_request.BindNewPipeAndPassReceiver());
+
+  mojo::PendingRemote<mojom::PaymentRequestClient> client_remote;
+  auto dummy_receiver = client_remote.InitWithNewPipeAndPassReceiver();
+
+  std::vector<mojom::PaymentMethodDataPtr> method_data;
+  method_data.push_back(CreateSpcMethodData());
+
+  payment_request->Init(std::move(client_remote), std::move(method_data),
+                        CreateDummyDetails(), mojom::PaymentOptions::New());
+  payment_request->Show(/*wait_for_updated_details=*/false,
+                        /*had_user_activation=*/true);
+
+  auto updated_details = mojom::PaymentDetails::New();
+  updated_details->total = mojom::PaymentItem::New();
+  updated_details->total->label = "Total";
+  updated_details->total->amount = mojom::PaymentCurrencyAmount::New();
+  updated_details->total->amount->currency = "USD";
+  updated_details->total->amount->value = "5000.00";
+  payment_request->UpdateWith(std::move(updated_details));
+
+  payment_request.FlushForTesting();
+  EXPECT_TRUE(bad_message_observer.got_bad_message());
+}
+
+// Tests that updateWith() is allowed when resolving the promise passed into
+// show() for "secure-payment-confirmation", because that update happens before
+// the dialog displays the details.
+TEST_P(PaymentRequestValidationTest,
+       SecurePaymentConfirmationAllowsUpdateWithForShowPromise) {
+  mojo::test::BadMessageObserver bad_message_observer;
+
+  mojo::Remote<mojom::PaymentRequest> payment_request;
+  new PaymentRequest(CreateMockDelegate(),
+                     payment_request.BindNewPipeAndPassReceiver());
+
+  mojo::PendingRemote<mojom::PaymentRequestClient> client_remote;
+  auto dummy_receiver = client_remote.InitWithNewPipeAndPassReceiver();
+
+  std::vector<mojom::PaymentMethodDataPtr> method_data;
+  method_data.push_back(CreateSpcMethodData());
+
+  payment_request->Init(std::move(client_remote), std::move(method_data),
+                        CreateDummyDetails(), mojom::PaymentOptions::New());
+  payment_request->Show(/*wait_for_updated_details=*/true,
+                        /*had_user_activation=*/true);
+
+  auto updated_details = mojom::PaymentDetails::New();
+  updated_details->total = mojom::PaymentItem::New();
+  updated_details->total->label = "Total";
+  updated_details->total->amount = mojom::PaymentCurrencyAmount::New();
+  updated_details->total->amount->currency = "USD";
+  updated_details->total->amount->value = "2.00";
+  payment_request->UpdateWith(std::move(updated_details));
+
+  payment_request.FlushForTesting();
+  EXPECT_FALSE(bad_message_observer.got_bad_message());
+}
+
 INSTANTIATE_TEST_SUITE_P(All, PaymentRequestValidationTest, testing::Bool());
 
 }  // namespace
diff --git a/components/payments/core/error_strings.cc b/components/payments/core/error_strings.cc
index 1ff7e00..6c518bc 100644
--- a/components/payments/core/error_strings.cc
+++ b/components/payments/core/error_strings.cc
@@ -34,6 +34,7 @@
 const char kPayerPhoneEmpty[] = "Payment app returned invalid response. Missing field \"payerPhone\".";
 const char kProhibitedOrigin[] = "Only localhost, file://, and cryptographic scheme origins allowed.";
 const char kProhibitedOriginOrInvalidSslExplanation[] = "No UI will be shown. CanMakePayment and hasEnrolledInstrument will always return false. Show will be rejected with NotSupportedError.";
+const char kSecurePaymentConfirmationUpdateWithNotAllowed[] = "Payment details cannot be updated for the \"secure-payment-confirmation\" method while it is showing.";
 const char kShippingAddressInvalid[] = "Payment app returned invalid shipping address in response.";
 const char kShippingOptionEmpty[] = "Payment app returned invalid response. Missing field \"shipping option\".";
 const char kShippingOptionIdRequired[] = "Shipping option identifier required.";
diff --git a/components/payments/core/error_strings.h b/components/payments/core/error_strings.h
index c9c91eb..647bb835 100644
--- a/components/payments/core/error_strings.h
+++ b/components/payments/core/error_strings.h
@@ -77,6 +77,10 @@
 // or kInvalidSslCertificate error.
 extern const char kProhibitedOriginOrInvalidSslExplanation[];
 
+// Mojo call PaymentRequest::UpdateWith() is not allowed for the
+// "secure-payment-confirmation" method once its dialog has been shown.
+extern const char kSecurePaymentConfirmationUpdateWithNotAllowed[];
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/components/payments/content/payment_request_unittest.cc b/components/payments/content/payment_request_unittest.cc
index ab8298b..4465d72 100644
--- a/components/payments/content/payment_request_unittest.cc
+++ b/components/payments/content/payment_request_unittest.cc
@@ -10,6 +10,7 @@
 
 #include "base/test/bind.h"
 #include "base/test/scoped_feature_list.h"
+#include "components/autofill/core/browser/data_manager/test_personal_data_manager.h"
 #include "components/payments/content/mock_content_payment_request_delegate.h"
 #include "components/payments/content/payment_request_display_manager.h"
 #include "content/public/browser/render_frame_host.h"
@@ -39,6 +40,8 @@
       scoped_features_.InitAndDisableFeature(
           ::features::kSecurePaymentConfirmation);
     }
+    personal_data_manager_.test_address_data_manager()
+        .SetAutofillProfileEnabled(false);
     NavigateAndCommit(page_url_);
   }
 
@@ -56,6 +59,10 @@
         .WillByDefault(testing::ReturnRefOfCopy(std::string("en-US")));
     ON_CALL(*delegate, GetLastCommittedURL())
         .WillByDefault(testing::ReturnRef(page_url_));
+    ON_CALL(*delegate, GetPersonalDataManager())
+        .WillByDefault(testing::Return(&personal_data_manager_));
+    ON_CALL(*delegate, IsBrowserWindowActive())
+        .WillByDefault(testing::Return(true));
     return delegate;
   }
 
@@ -66,6 +73,8 @@
     if (IsSecurePaymentConfirmationEnabled()) {
       spc_method->secure_payment_confirmation =
           mojom::SecurePaymentConfirmationRequest::New();
+      spc_method->secure_payment_confirmation->credential_ids.push_back(
+          {1, 2, 3, 4});
       spc_method->secure_payment_confirmation->challenge = {1, 2, 3, 4};
       spc_method->secure_payment_confirmation->rp_id = "rp.id";
       spc_method->secure_payment_confirmation->instrument =
@@ -92,6 +101,7 @@
   }
 
   const GURL page_url_{"https://a.com"};
+  autofill::TestPersonalDataManager personal_data_manager_;
   PaymentRequestDisplayManager display_manager_;
   base::test::ScopedFeatureList scoped_features_;
 };
@@ -188,6 +198,75 @@
   }
 }
 
+// Tests that the payment details cannot be updated for a
+// "secure-payment-confirmation" request after it has been shown, because the
+// dialog displays a snapshot of the details and would not reflect changes made
+// while it was visible.
+TEST_P(PaymentRequestValidationTest,
+       SecurePaymentConfirmationRejectsUpdateWith) {
+  mojo::test::BadMessageObserver bad_message_observer;
+
+  mojo::Remote<mojom::PaymentRequest> payment_request;
+  new PaymentRequest(CreateMockDelegate(),
+                     payment_request.BindNewPipeAndPassReceiver());
+
+  mojo::PendingRemote<mojom::PaymentRequestClient> client_remote;
+  auto dummy_receiver = client_remote.InitWithNewPipeAndPassReceiver();
+
+  std::vector<mojom::PaymentMethodDataPtr> method_data;
+  method_data.push_back(CreateSpcMethodData());
+
+  payment_request->Init(std::move(client_remote), std::move(method_data),
+                        CreateDummyDetails(), mojom::PaymentOptions::New());
+  payment_request->Show(/*wait_for_updated_details=*/false,
+                        /*had_user_activation=*/true);
+
+  auto updated_details = mojom::PaymentDetails::New();
+  updated_details->total = mojom::PaymentItem::New();
+  updated_details->total->label = "Total";
+  updated_details->total->amount = mojom::PaymentCurrencyAmount::New();
+  updated_details->total->amount->currency = "USD";
+  updated_details->total->amount->value = "5000.00";
+  payment_request->UpdateWith(std::move(updated_details));
+
+  payment_request.FlushForTesting();
+  EXPECT_TRUE(bad_message_observer.got_bad_message());
+}
+
+// Tests that updateWith() is allowed when resolving the promise passed into
+// show() for "secure-payment-confirmation", because that update happens before
+// the dialog displays the details.
+TEST_P(PaymentRequestValidationTest,
+       SecurePaymentConfirmationAllowsUpdateWithForShowPromise) {
+  mojo::test::BadMessageObserver bad_message_observer;
+
+  mojo::Remote<mojom::PaymentRequest> payment_request;
+  new PaymentRequest(CreateMockDelegate(),
+                     payment_request.BindNewPipeAndPassReceiver());
+
+  mojo::PendingRemote<mojom::PaymentRequestClient> client_remote;
+  auto dummy_receiver = client_remote.InitWithNewPipeAndPassReceiver();
+
+  std::vector<mojom::PaymentMethodDataPtr> method_data;
+  method_data.push_back(CreateSpcMethodData());
+
+  payment_request->Init(std::move(client_remote), std::move(method_data),
+                        CreateDummyDetails(), mojom::PaymentOptions::New());
+  payment_request->Show(/*wait_for_updated_details=*/true,
+                        /*had_user_activation=*/true);
+
+  auto updated_details = mojom::PaymentDetails::New();
+  updated_details->total = mojom::PaymentItem::New();
+  updated_details->total->label = "Total";
+  updated_details->total->amount = mojom::PaymentCurrencyAmount::New();
+  updated_details->total->amount->currency = "USD";
+  updated_details->total->amount->value = "2.00";
+  payment_request->UpdateWith(std::move(updated_details));
+
+  payment_request.FlushForTesting();
+  EXPECT_FALSE(bad_message_observer.got_bad_message());
+}
+
 INSTANTIATE_TEST_SUITE_P(All, PaymentRequestValidationTest, testing::Bool());
 
 }  // namespace
Loading diff…

Original Bug Report

reported by vm...@google.com

SPC TOCTOU: Signed transaction amount can be mutated after user approval

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: A logic flaw in Secure Payment Confirmation (SPC) allows a compromised renderer to change the transaction amount via Mojo IPC after it is displayed to the user. Because the UI does not update but the signing logic reads the live state, the authenticator signs an attacker-controlled value. This breaks the “What You See Is What You Sign” guarantee.

Affected files:

  • components/payments/content/payment_request.cc
  • components/payments/content/secure_payment_confirmation_app.cc
  • components/payments/content/secure_payment_confirmation_controller.cc
  • components/payments/content/payment_request_spec.cc

Estimated timestamp from git blame: 2026-01-09

Summary

Secure Payment Confirmation (SPC) is designed to ensure that a user cryptographically signs a transaction for a specific amount and payee shown in a trusted browser UI. A potential Time-of-Check to Time-of-Use (TOCTOU) vulnerability exists in the browser’s handling of the PaymentRequest::UpdateWith Mojo IPC. A compromised renderer can mutate the transaction details after the trusted UI has been displayed. Because the SPC controller does not observe these changes, but the signing logic later reads the live state from the PaymentRequestSpec, the authenticator signs an attacker-controlled amount that the user never approved.

Root Cause

The vulnerability arises from two unsynchronized reads of mutable state in the PaymentRequestSpec object, combined with a lack of UI observation:

  1. Display Read (Time of Check): In SecurePaymentConfirmationController::SetupModelAndShowDialogIfApplicable, the total amount is read from request_->spec()->GetTotal(app) and stored in the UI model. Critically, the controller is not a PaymentRequestSpec::Observer and does not handle spec updates, so the displayed amount remains static even if the underlying spec changes.
  2. State Mutation: A compromised renderer can trigger a mutation via the PaymentRequest::UpdateWith Mojo IPC handler in components/payments/content/payment_request.cc. This function verifies that the payment request is showing, but it unconditionally updates the PaymentRequestSpec via spec_->UpdateWith(std::move(details)). The transaction total is overwritten, but the UI is never notified.
  3. Sign Read (Time of Use): When the user clicks “Verify”, the signing flow invokes SecurePaymentConfirmationApp::OnGetBrowserBoundKey. This function dynamically reads the amount again using spec_->GetTotal(this)->amount.Clone() to pass it to the authenticator for signing.

Since the SPC UI is not updated during step 2, it continues to display the original amount while the signing process uses the new, malicious amount.

Potential Reproduction Steps

Note: These are suggested steps based on code analysis; our tooling cannot currently execute a live PoC.

  1. From a compromised renderer, initiate a PaymentRequest for Secure Payment Confirmation with a legitimate amount (e.g., $5.00).
  2. Wait for the browser to display the SPC dialog showing $5.00.
  3. Send a PaymentRequest::UpdateWith Mojo IPC from the renderer with a modified amount (e.g., $5000.00).
  4. The browser process processes the IPC and updates the PaymentRequestSpec to $5000.00. The UI remains unchanged, still showing $5.00.
  5. The user clicks “Verify”, believing they are approving a $5.00 transaction.
  6. The browser process reads the newly updated $5000.00 from PaymentRequestSpec and includes it in the PaymentOptions to be signed by the authenticator.
  7. The user performs biometric authentication, and the authenticator signs the $5000.00 transaction hash.

Suggested Fix

There are a few ways to resolve this:

  1. Snapshotting: Snapshot the exact PaymentOptions (including the amount and payee) at the exact moment the SPC UI is generated (Time of Check), and use that immutable snapshot for the authenticator signing request (Time of Use).
  2. Reject Updates: Modify PaymentRequest::UpdateWith to explicitly reject updates if the currently selected app is an SPC app, as SPC does not fundamentally support dynamic cart updates during the verification prompt.
  3. Observation & Abort: Have SecurePaymentConfirmationController observe PaymentRequestSpec. If the spec is updated while the dialog is showing, either securely refresh the UI or abort the payment flow entirely.

Evaluated with Chrome root at commit: ff3d2b74fa39431785bd60e51463b08fcc71ee33


Results from 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