CVE-2026-87635
Overview
Background
- Secure Payment Confirmation (SPC)
- a Web Payments flow where the browser shows a transaction dialog (payee, instrument, total) and the user authenticates with a passkey, producing a signed cryptogram over client data that attests to what was displayed.
- `PaymentRequestSpec`
- the browser-side object holding the merchant-supplied payment details;
getRawTotal()/GetRawTotal()returns the unmodified total, whileGetTotal(selected_app)returns the total with any applicablePaymentDetailsModifierapplied for that app. - Payment modifiers
- merchant-supplied
PaymentDetailsModifierentries that can override the displayed total for specific payment methods/apps, so the “raw” total and the “modified” total can legitimately differ. - Client data / signed cryptogram
- the
PaymentOptions(including the total amount) thatSecurePaymentConfirmationApphands to the authenticator to be signed and later verified by the relying party.
Root Cause Analysis
For SPC the total amount was fetched from the mutable PaymentRequestSpec twice along two independent paths: the Android dialog fetched it via mSpec.getRawTotal() for display, while SecurePaymentConfirmationApp::InvokePaymentApp fetched it via spec_->GetTotal(/*selected_app=*/this) (modifiers applied) when building the PaymentOptions that get signed. Because one path applied modifiers and the other did not — and because the spec could also change between the two reads — the value shown to the user could diverge from the value embedded in the signed client data, violating the SPC invariant that “the displayed total equals the signed total.”
The fix removes the second, independent read: SecurePaymentConfirmationApp now stores one frozen total_ set once at app-creation time via SetTotal() (guarded by CHECK(!total_)), and both the signing path (GetTotalForSpc()->amount.Clone()) and the Android display path (selectedPaymentApp.getTotalForSpc()) read that same frozen copy. This makes a single value the source of truth, so no later modifier evaluation or spec mutation can desynchronize display from signature; the change also drops the app’s spec_ weak pointer entirely.
SecurePaymentConfirmationApp at creation time and routes both the dialog and the client data through it.Attack Path
- Merchant initiates an SPC PaymentRequest A merchant page starts Secure Payment Confirmation with payment details that include a total and one or more modifiers applicable to the SPC instrument.
- Divergent totals are produced
The Android dialog reads
mSpec.getRawTotal()(unmodified) to render the amount the user sees, whileSecurePaymentConfirmationAppreadsspec_->GetTotal(this)(modifier-applied), so the two totals differ. - User authenticates on a misleading amount The user approves the dialog believing they are confirming the displayed (raw) total, but the authenticator signs client data containing the different, modifier-applied total.
- Relying party verifies the unseen amount The signed cryptogram attests to a total the user never saw, letting the merchant obtain a valid signature over an amount that misrepresents what was displayed (inferred — the patch demonstrates the display/signature mismatch but not a specific end-to-end fraud outcome).
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcomponents/payments/content/android/java/src/org/chromium/components/payments/PaymentRequestService.java |
modified | |
ifcomponents/payments/content/secure_payment_confirmation_app.cc |
modified | |
BrowserBoundKeycomponents/payments/content/secure_payment_confirmation_app.h |
modified | |
PasskeyBrowserBindercomponents/payments/content/secure_payment_confirmation_app.h |
modified | |
PaymentRequestSpeccomponents/payments/content/secure_payment_confirmation_app.h |
modified |
Files Changed
components/payments/content/android/java/src/org/chromium/components/payments/JniPaymentApp.javacomponents/payments/content/secure_payment_confirmation_app.cccomponents/payments/content/android/java/src/org/chromium/components/payments/PaymentRequestService.javacomponents/payments/content/android/java/src/org/chromium/components/payments/PaymentApp.javacomponents/payments/content/secure_payment_confirmation_app_unittest.cccomponents/payments/content/secure_payment_confirmation_app.hcomponents/payments/content/mock_payment_app_factory_delegate.cccomponents/payments/content/secure_payment_confirmation_controller.cccomponents/payments/content/secure_payment_confirmation_app_factory_unittest.ccchrome/android/java/src/org/chromium/chrome/browser/payments/ChromePaymentRequestService.javacomponents/payments/content/android/jni_payment_app.hcomponents/payments/content/secure_payment_confirmation_app_factory.cccomponents/payments/content/android/jni_payment_app.cccomponents/payments/content/payment_app.cccomponents/payments/content/payment_app.h
Audit Directions
- Display-vs-attestation dual readsLook for any security-sensitive UI where a value shown to the user and the value later signed, hashed, or sent to a server are read from the same mutable object on separate paths; require a single frozen copy captured at display time.
- Modifier / re-fetch sensitivityAudit code that fetches totals or details through both raw and modifier-applying accessors (
getRawTotalvsGetTotal), or that re-reads a spec after it can be mutated viaupdateWith(), for divergence between what is shown and what is committed. - Freeze-once invariantsVerify that values which must not change while a dialog is visible are enforced with set-once guards (
CHECK(!total_)/SetTotal) rather than left as live references to objects the renderer can still update.
Patch
From e5ce54350a6811a902f1dd0eb80bde016b72e7bf Mon Sep 17 00:00:00 2001
From: Slobodan Pejic <slobodan@chromium.org>
Date: Tue, 04 Aug 2026 14:15:06 -0700
Subject: [PATCH] Freeze SPC payment details at app creation time
Before this change the total amount was retrieved twice from the spec
object, once to display the total in the Secure Payment Confirmation
dialog, then once more to be included in the signed client data. The
Android dialog retrieved the raw total for display whereas the
SecurePaymentConfirmationApp retrieved the total with modifiers applied
for inclusion into client data, which can result in a mismatch of the
displayed value and the value included in client data.
After this change, SecurePaymentConfirmationApp holds a frozen central
copy of the total for use in both display and inclusion in client data.
And, the Android version now retrieves the total from
SecurePaymentConfirmationApp instead of the spec.
Tradeoff: The generic PaymentApp interface has a new getTotalForSpc()
method that is only used by Secure Payment Confirmation. Note that this
is already the case for the getPaymentEntitiesLogos() method on the same
PaymentApp interface.
This complements the fix in crrev.com/c/7984525 which rejects
updateWith() calls after the SPC dialog has been shown.
Bug: 522304737
Change-Id: I93b88ee164d40dcbc2f877358141c14e0098dd46
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7989196
Commit-Queue: Slobodan Pejic <slobodan@chromium.org>
Reviewed-by: Stephen McGruer <smcgruer@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1673662}
---
diff --git a/chrome/android/java/src/org/chromium/chrome/browser/payments/ChromePaymentRequestService.java b/chrome/android/java/src/org/chromium/chrome/browser/payments/ChromePaymentRequestService.java
index fbdc8cb..0dd24f9f 100644
--- a/chrome/android/java/src/org/chromium/chrome/browser/payments/ChromePaymentRequestService.java
+++ b/chrome/android/java/src/org/chromium/chrome/browser/payments/ChromePaymentRequestService.java
@@ -373,13 +373,13 @@
}
mSpcController = null;
};
- PaymentItem rawTotal = mSpec.getRawTotal();
- assert rawTotal != null;
PaymentApp selectedPaymentApp = getSelectedPaymentApp();
assert selectedPaymentApp != null;
assert selectedPaymentApp.getLabel() != null;
assert selectedPaymentApp.getDrawableIcon() != null;
+ PaymentItem total = selectedPaymentApp.getTotalForSpc();
+
mSpcController =
new SecurePaymentConfirmationController(
windowAndroid,
@@ -388,7 +388,7 @@
getPayeeOrigin(spcMethodData.securePaymentConfirmation),
selectedPaymentApp.getLabel(),
selectedPaymentApp.getSublabel(),
- rawTotal,
+ total,
selectedPaymentApp.getDrawableIcon(),
spcMethodData.securePaymentConfirmation.rpId,
spcMethodData.securePaymentConfirmation.showOptOut,
@@ -471,6 +471,8 @@
}
mSpcController = null;
};
+ PaymentItem total = selectedPaymentApp.getTotalForSpc();
+
mSpcController =
new SecurePaymentConfirmationController(
windowAndroid,
@@ -479,7 +481,7 @@
getPayeeOrigin(spcMethodData.securePaymentConfirmation),
selectedPaymentApp.getLabel(),
selectedPaymentApp.getSublabel(),
- mSpec.getRawTotal(),
+ total,
selectedPaymentApp.getDrawableIcon(),
spcMethodData.securePaymentConfirmation.rpId,
spcMethodData.securePaymentConfirmation.showOptOut,
diff --git a/components/payments/content/android/java/src/org/chromium/components/payments/JniPaymentApp.java b/components/payments/content/android/java/src/org/chromium/components/payments/JniPaymentApp.java
index 462b3f4c..fdb597ce 100644
--- a/components/payments/content/android/java/src/org/chromium/components/payments/JniPaymentApp.java
+++ b/components/payments/content/android/java/src/org/chromium/components/payments/JniPaymentApp.java
@@ -31,6 +31,7 @@
import java.util.HashSet;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import java.util.Set;
/** Wrapper around a C++ payment app. */
@@ -196,6 +197,13 @@
}
@Override
+ public PaymentItem getTotalForSpc() {
+ byte[] byteResult = JniPaymentAppJni.get().getTotalForSpc(mNativeObject);
+ Objects.requireNonNull(byteResult, "The secure payment app must provide the total.");
+ return PaymentItem.deserialize(ByteBuffer.wrap(byteResult));
+ }
+
+ @Override
public void invokePaymentApp(
String id,
String merchantName,
@@ -309,6 +317,8 @@
boolean canPreselect(long nativeJniPaymentApp);
+ byte[] getTotalForSpc(long nativeJniPaymentApp);
+
void invokePaymentApp(long nativeJniPaymentApp, JniPaymentApp callback);
void updateWith(long nativeJniPaymentApp, ByteBuffer responseByteBuffer);
diff --git a/components/payments/content/android/java/src/org/chromium/components/payments/PaymentApp.java b/components/payments/content/android/java/src/org/chromium/components/payments/PaymentApp.java
index 45cbc70e..fa70476 100644
--- a/components/payments/content/android/java/src/org/chromium/components/payments/PaymentApp.java
+++ b/components/payments/content/android/java/src/org/chromium/components/payments/PaymentApp.java
@@ -204,25 +204,24 @@
/**
* Invoke the payment app to retrieve the payment details.
*
- * The callback will be invoked with the resulting payment details or error.
+ * <p>The callback will be invoked with the resulting payment details or error.
*
- * @param id The unique identifier of the PaymentRequest.
- * @param merchantName The name of the merchant.
- * @param origin The origin of this merchant.
- * @param iframeOrigin The origin of the iframe that invoked PaymentRequest.
+ * @param id The unique identifier of the PaymentRequest.
+ * @param merchantName The name of the merchant.
+ * @param origin The origin of this merchant.
+ * @param iframeOrigin The origin of the iframe that invoked PaymentRequest.
* @param certificateChain The site certificate chain of the merchant. Can be null when
- * ANDROID_PAYMENT_INTENTS_OMIT_DEPRECATED_PARAMETERS is enabled or for
- * localhost or local file, which are secure contexts without SSL. Each
- * byte array cannot be null.
- * @param methodDataMap The payment-method specific data for all applicable payment methods,
- * e.g., whether the app should be invoked in test or production, a
- * merchant identifier, or a public key.
- * @param total The total amount.
- * @param displayItems The shopping cart items.
- * @param modifiers The relevant payment details modifiers.
- * @param paymentOptions The payment options of the PaymentRequest.
- * @param shippingOptions The shipping options of the PaymentRequest.
- * @param callback The object that will receive the payment details.
+ * ANDROID_PAYMENT_INTENTS_OMIT_DEPRECATED_PARAMETERS is enabled or for localhost or local
+ * file, which are secure contexts without SSL. Each byte array cannot be null.
+ * @param methodDataMap The payment-method specific data for all applicable payment methods,
+ * e.g., whether the app should be invoked in test or production, a merchant identifier, or
+ * a public key.
+ * @param total The total amount.
+ * @param displayItems The shopping cart items.
+ * @param modifiers The relevant payment details modifiers.
+ * @param paymentOptions The payment options of the PaymentRequest.
+ * @param shippingOptions The shipping options of the PaymentRequest.
+ * @param callback The object that will receive the payment details.
*/
public void invokePaymentApp(
String id,
@@ -333,6 +332,14 @@
}
/**
+ * @return The total amount for this payment app. Must only be called for the SPC payment app.
+ */
+ public PaymentItem getTotalForSpc() {
+ throw new IllegalStateException(
+ "getTotalForSpc() must only be called for secure payment app.");
+ }
+
+ /**
* @return The payment entities logos, an unmodifiable {@link List} (Secure Payment Confirmation
* specific).
*/
diff --git a/components/payments/content/android/java/src/org/chromium/components/payments/PaymentRequestService.java b/components/payments/content/android/java/src/org/chromium/components/payments/PaymentRequestService.java
index cd3b47c4..5077bfd 100644
--- a/components/payments/content/android/java/src/org/chromium/components/payments/PaymentRequestService.java
+++ b/components/payments/content/android/java/src/org/chromium/components/payments/PaymentRequestService.java
@@ -986,7 +986,8 @@
if (mBrowserPaymentRequest.showNoMatchingPaymentCredential()) {
if (sNativeObserverForTest != null) {
sNativeObserverForTest.onAppListReady(
- mBrowserPaymentRequest.getPaymentApps(), mSpec.getRawTotal());
+ mBrowserPaymentRequest.getPaymentApps(),
+ mBrowserPaymentRequest.getPaymentApps().get(0).getTotalForSpc());
}
return null;
}
diff --git a/components/payments/content/android/jni_payment_app.cc b/components/payments/content/android/jni_payment_app.cc
index 1e79ea5..d0b2d52 100644
--- a/components/payments/content/android/jni_payment_app.cc
+++ b/components/payments/content/android/jni_payment_app.cc
@@ -114,6 +114,12 @@
return payment_app_->CanPreselect();
}
+ScopedJavaLocalRef<jbyteArray> JniPaymentApp::GetTotalForSpc(JNIEnv* env) {
+ const mojom::PaymentItemPtr& total = payment_app_->GetTotalForSpc();
+ return base::android::ToJavaByteArray(env,
+ mojom::PaymentItem::Serialize(&total));
+}
+
void JniPaymentApp::InvokePaymentApp(JNIEnv* env,
const JavaRef<jobject>& jcallback) {
invoke_callback_ = jcallback;
diff --git a/components/payments/content/android/jni_payment_app.h b/components/payments/content/android/jni_payment_app.h
index 01ef7b5b..7dc9c17 100644
--- a/components/payments/content/android/jni_payment_app.h
+++ b/components/payments/content/android/jni_payment_app.h
@@ -44,6 +44,8 @@
bool CanPreselect(JNIEnv* env);
+ base::android::ScopedJavaLocalRef<jbyteArray> GetTotalForSpc(JNIEnv* env);
+
void InvokePaymentApp(JNIEnv* env,
const base::android::JavaRef<jobject>& jcallback);
diff --git a/components/payments/content/mock_payment_app_factory_delegate.cc b/components/payments/content/mock_payment_app_factory_delegate.cc
index 81c2043..6a5b09a0 100644
--- a/components/payments/content/mock_payment_app_factory_delegate.cc
+++ b/components/payments/content/mock_payment_app_factory_delegate.cc
@@ -21,6 +21,10 @@
mojom::PaymentMethodDataPtr method_data) {
auto details = mojom::PaymentDetails::New();
details->id = "id";
+ details->total = mojom::PaymentItem::New();
+ details->total->amount = mojom::PaymentCurrencyAmount::New();
+ details->total->amount->currency = "USD";
+ details->total->amount->value = "1.25";
std::vector<mojom::PaymentMethodDataPtr> methods;
methods.emplace_back(std::move(method_data));
diff --git a/components/payments/content/payment_app.cc b/components/payments/content/payment_app.cc
index 3ee325a..53a0e1c 100644
--- a/components/payments/content/payment_app.cc
+++ b/components/payments/content/payment_app.cc
@@ -8,6 +8,7 @@
#include <optional>
#include "base/functional/callback.h"
+#include "base/notreached.h"
#include "url/origin.h"
namespace payments {
@@ -57,6 +58,11 @@
return {};
}
+const mojom::PaymentItemPtr& PaymentApp::GetTotalForSpc() const {
+ NOTREACHED()
+ << "Only SecurePaymentConfirmationApp is expected to return the total.";
+}
+
std::string PaymentApp::GetApplicationIdentifierToHide() const {
return std::string();
}
diff --git a/components/payments/content/payment_app.h b/components/payments/content/payment_app.h
index c33eef1..769a610 100644
--- a/components/payments/content/payment_app.h
+++ b/components/payments/content/payment_app.h
@@ -121,6 +121,9 @@
// Returns the payment entities logos to be displayed to the user.
virtual std::vector<PaymentEntityLogo*> GetPaymentEntitiesLogos();
+ // Returns the total amount for this payment app, if any.
+ virtual const mojom::PaymentItemPtr& GetTotalForSpc() const;
+
// Returns the identifier for another payment app that should be hidden when
// this payment app is present.
virtual std::string GetApplicationIdentifierToHide() const;
diff --git a/components/payments/content/secure_payment_confirmation_app.cc b/components/payments/content/secure_payment_confirmation_app.cc
index ee79e16..bb28e134e 100644
--- a/components/payments/content/secure_payment_confirmation_app.cc
+++ b/components/payments/content/secure_payment_confirmation_app.cc
@@ -23,7 +23,6 @@
#include "base/values.h"
#include "components/payments/content/browser_binding/browser_bound_key.h"
#include "components/payments/content/browser_binding/passkey_browser_binder.h"
-#include "components/payments/content/payment_request_spec.h"
#include "components/payments/core/error_strings.h"
#include "components/payments/core/features.h"
#include "components/payments/core/method_strings.h"
@@ -74,7 +73,6 @@
std::unique_ptr<PasskeyBrowserBinder> passkey_browser_binder,
bool device_supports_browser_bound_keys_in_hardware,
const url::Origin& merchant_origin,
- base::WeakPtr<PaymentRequestSpec> spec,
mojom::SecurePaymentConfirmationRequestPtr request,
std::unique_ptr<webauthn::InternalAuthenticator> authenticator,
std::vector<PaymentApp::PaymentEntityLogo> payment_entities_logos,
@@ -90,7 +88,6 @@
payment_instrument_icon_(std::move(payment_instrument_icon)),
credential_id_(std::move(credential_id)),
merchant_origin_(merchant_origin),
- spec_(spec),
request_(std::move(request)),
authenticator_(std::move(authenticator)),
passkey_browser_binder_(std::move(passkey_browser_binder)),
@@ -105,10 +102,9 @@
void SecurePaymentConfirmationApp::InvokePaymentApp(
base::WeakPtr<Delegate> delegate) {
- if (!authenticator_ || !spec_)
+ if (!authenticator_) {
return;
-
- DCHECK(spec_->IsInitialized());
+ }
auto options = blink::mojom::PublicKeyCredentialRequestOptions::New();
options->relying_party_id = effective_relying_party_identity_;
@@ -240,6 +236,18 @@
return filtered_logos;
}
+void SecurePaymentConfirmationApp::SetTotal(mojom::PaymentItemPtr total) {
+ CHECK(!total_)
+ << "The total should only be set once on SecurePaymentConfirmationApp";
+ total_ = std::move(total);
+}
+
+const mojom::PaymentItemPtr& SecurePaymentConfirmationApp::GetTotalForSpc()
+ const {
+ CHECK(total_) << "SetTotal() must be called before GetTotalForSpc() is";
+ return total_;
+}
+
bool SecurePaymentConfirmationApp::IsValidForModifier(
const std::string& method) const {
bool is_valid = false;
@@ -369,8 +377,8 @@
base::UTF16ToUTF8(logo.label)));
}
authenticator_->SetPaymentOptions(blink::mojom::PaymentOptions::New(
- spec_->GetTotal(/*selected_app=*/this)->amount.Clone(),
- std::move(instrument), request_->payee_name, request_->payee_origin,
+ GetTotalForSpc()->amount.Clone(), std::move(instrument),
+ request_->payee_name, request_->payee_origin,
/*payment_entities_logos=*/std::move(payment_entities_logos),
std::move(browser_bound_public_key)));
diff --git a/components/payments/content/secure_payment_confirmation_app.h b/components/payments/content/secure_payment_confirmation_app.h
index 1fddbd42..baf29c4 100644
--- a/components/payments/content/secure_payment_confirmation_app.h
+++ b/components/payments/content/secure_payment_confirmation_app.h
@@ -35,7 +35,6 @@
class BrowserBoundKey;
class PasskeyBrowserBinder;
-class PaymentRequestSpec;
// These values are persisted to logs. Entries should not be renumbered and
// numeric values should never be reused. Keep in sync with
@@ -61,7 +60,6 @@
std::unique_ptr<PasskeyBrowserBinder> passkey_browser_binder,
bool device_supports_browser_bound_keys_in_hardware,
const url::Origin& merchant_origin,
- base::WeakPtr<PaymentRequestSpec> spec,
mojom::SecurePaymentConfirmationRequestPtr request,
std::unique_ptr<webauthn::InternalAuthenticator> authenticator,
std::vector<PaymentApp::PaymentEntityLogo> payment_entities_logos,
@@ -85,6 +83,7 @@
std::u16string GetSublabel() const override;
const SkBitmap* icon_bitmap() const override;
std::vector<PaymentEntityLogo*> GetPaymentEntitiesLogos() override;
+ const mojom::PaymentItemPtr& GetTotalForSpc() const override;
bool IsValidForModifier(const std::string& method) const override;
base::WeakPtr<PaymentApp> AsWeakPtr() override;
bool HandlesShippingAddress() const override;
@@ -105,6 +104,16 @@
// Returns whether the SPC App should show an error dialog.
bool IsErrorDialog() const { return is_error_dialog_; }
+ // Sets the total. The total must be set before invoking and must only be set
+ // once since in secure payment confirmation the total shown must match total
+ // in the signed client data.
+ //
+ // This cannot be passed to the constructor because calculating the total
+ // via `PaymentRequestSpec::GetTotal()` requires a pointer to the constructed
+ // `PaymentApp` to check for applicable modifiers, creating a circular
+ // dependency.
+ void SetTotal(mojom::PaymentItemPtr total);
+
PasskeyBrowserBinder* GetPasskeyBrowserBinderForTesting();
webauthn::InternalAuthenticator* authenticator_for_testing() {
return authenticator_.get();
@@ -132,13 +141,17 @@
// references.
content::GlobalRenderFrameHostId authenticator_frame_routing_id_;
+ // These values are taken by copy since they may not be changed while the SPC
+ // dialog is showing, ensuring that the values in the signed cryptogram match
+ // the displayed values.
const std::string effective_relying_party_identity_;
const std::u16string payment_instrument_label_;
const std::u16string payment_instrument_details_;
const std::unique_ptr<SkBitmap> payment_instrument_icon_;
... (file diff truncated)
Regression Test / PoC
diff --git a/components/payments/content/secure_payment_confirmation_app_factory_unittest.cc b/components/payments/content/secure_payment_confirmation_app_factory_unittest.cc
index e2d70056..4837b816 100644
--- a/components/payments/content/secure_payment_confirmation_app_factory_unittest.cc
+++ b/components/payments/content/secure_payment_confirmation_app_factory_unittest.cc
@@ -9,6 +9,7 @@
#include "base/base64.h"
#include "base/memory/scoped_refptr.h"
+#include "base/run_loop.h"
#include "base/test/gmock_callback_support.h"
#include "base/test/gmock_move_support.h"
#include "base/test/metrics/histogram_tester.h"
@@ -46,10 +47,12 @@
namespace {
using ::base::test::RunOnceCallback;
+using ::base::test::RunOnceClosure;
using ::testing::_;
using ::testing::ByMove;
using ::testing::Eq;
using ::testing::Field;
+using ::testing::Invoke;
using ::testing::IsEmpty;
using ::testing::IsNull;
using ::testing::Matcher;
@@ -101,6 +104,7 @@
mock_authenticator_ = CreateMockInternalAuthenticator();
mock_service_ = base::MakeRefCounted<MockWebPaymentsWebDataService>();
+ done_creating_apps_loop_ = std::make_unique<base::RunLoop>();
}
std::unique_ptr<MockPaymentAppFactoryDelegate> CreateMockDelegate(
@@ -116,6 +120,9 @@
.WillByDefault(testing::Return(
mock_content_payment_request_delegate_.GetContentWeakPtr()));
#endif
+ ON_CALL(*mock_delegate, OnDoneCreatingPaymentApps())
+ .WillByDefault(Invoke(this, &SecurePaymentConfirmationAppFactoryTest::
+ OnDoneCreatingPaymentApps));
return mock_delegate;
}
@@ -131,6 +138,13 @@
return mock_authenticator;
}
+ void WaitForDoneCreatingApps() {
+ done_creating_apps_loop_->Run();
+ done_creating_apps_loop_ = std::make_unique<base::RunLoop>();
+ }
+
+ void OnDoneCreatingPaymentApps() { done_creating_apps_loop_->Quit(); }
+
// Creates and returns a minimal SecurePaymentConfirmationRequest object with
// only required fields filled in to pass parsing.
//
@@ -187,6 +201,7 @@
mock_credential_finder_;
scoped_refptr<FakeBrowserBoundKeyStore> browser_bound_key_store_ =
base::MakeRefCounted<FakeBrowserBoundKeyStore>();
+ std::unique_ptr<base::RunLoop> done_creating_apps_loop_;
private:
crypto::ScopedFakeUnexportableKeyProvider scoped_key_provider_;
@@ -241,7 +256,7 @@
static_cast<content::TestWebContents*>(web_contents_.get())
->TestDidDownloadImage(icon, /*http_status_code=*/200,
std::move(icon_bitmaps), std::move(icon_sizes));
-
+ WaitForDoneCreatingApps();
histogram_tester.ExpectUniqueSample(
"PaymentRequest.SecurePaymentConfirmation."
"UserVerifyingPlatformAuthenticatorAvailable",
@@ -314,6 +329,8 @@
->TestDidDownloadImage(icon, /*http_status_code=*/200,
std::move(icon_bitmaps), std::move(icon_sizes));
+ WaitForDoneCreatingApps();
+
ASSERT_TRUE(secure_payment_confirmation_app);
EXPECT_EQ(secure_payment_confirmation_app->GetSublabel(),
u"instrument details");
@@ -355,6 +372,8 @@
static_cast<content::TestWebContents*>(web_contents_.get())
->TestDidDownloadImage(icon, /*http_status_code=*/200,
std::move(icon_bitmaps), std::move(icon_sizes));
+
+ WaitForDoneCreatingApps();
}
// Test that SecurePaymentConfirmationAppFactory passes the input credentials,
@@ -427,6 +446,7 @@
->TestDidDownloadImage(icon, /*http_status_code=*/200,
std::move(icon_bitmaps), std::move(icon_sizes));
+ WaitForDoneCreatingApps();
ASSERT_TRUE(secure_payment_confirmation_app);
PasskeyBrowserBinder* passkey_browser_binder =
static_cast<SecurePaymentConfirmationApp*>(
@@ -453,22 +473,15 @@
CreateSecurePaymentConfirmationRequest();
GURL icon = method_data->secure_payment_confirmation->instrument->icon;
- std::unique_ptr<webauthn::MockInternalAuthenticator> mock_authenticator =
- CreateMockInternalAuthenticator(
- {.is_user_verifying_platform_authenticator_available = false});
+ mock_authenticator_ = CreateMockInternalAuthenticator(
+ {.is_user_verifying_platform_authenticator_available = false});
- auto mock_delegate = std::make_unique<MockPaymentAppFactoryDelegate>(
- web_contents_, std::move(method_data));
+ std::unique_ptr<MockPaymentAppFactoryDelegate> mock_delegate =
+ CreateMockDelegate(std::move(method_data));
url::Origin caller_origin = url::Origin::Create(GURL("https://site.example"));
EXPECT_CALL(*mock_delegate, GetFrameSecurityOrigin())
.WillRepeatedly(ReturnRef(caller_origin));
- scoped_refptr<MockWebPaymentsWebDataService> mock_service =
- base::MakeRefCounted<MockWebPaymentsWebDataService>();
- EXPECT_CALL(*mock_delegate, CreateInternalAuthenticator())
- .WillOnce(Return(ByMove(std::move(mock_authenticator))));
- EXPECT_CALL(*mock_delegate, GetWebPaymentsWebDataService())
- .WillRepeatedly(Return(mock_service));
std::unique_ptr<PaymentApp> secure_payment_confirmation_app;
EXPECT_CALL(*mock_delegate, OnPaymentAppCreated(_))
.WillOnce(MoveArg<0>(&secure_payment_confirmation_app));
@@ -483,6 +496,8 @@
->TestDidDownloadImage(icon, /*http_status_code=*/200,
std::move(icon_bitmaps), std::move(icon_sizes));
+ WaitForDoneCreatingApps();
+
ASSERT_TRUE(secure_payment_confirmation_app);
EXPECT_FALSE(secure_payment_confirmation_app->HasEnrolledInstrument());
@@ -530,7 +545,7 @@
static_cast<content::TestWebContents*>(web_contents_.get())
->TestDidDownloadImage(icon, /*http_status_code=*/200,
std::move(icon_bitmaps), std::move(icon_sizes));
-
+ WaitForDoneCreatingApps();
ASSERT_TRUE(secure_payment_confirmation_app);
EXPECT_FALSE(secure_payment_confirmation_app->HasEnrolledInstrument());
@@ -584,6 +599,7 @@
static_cast<content::TestWebContents*>(web_contents_.get())
->TestDidDownloadImage(icon, /*http_status_code=*/200,
std::move(icon_bitmaps), std::move(icon_sizes));
+ WaitForDoneCreatingApps();
}
// Class wrapping tests relating to payment entity logos support in
@@ -675,6 +691,8 @@
FakeImageDownloaded(kPaymentEntity2LogoUrl, /*succeeded=*/true,
/*height=*/60);
+ WaitForDoneCreatingApps();
+
// Even though the third and fourth entity logos were not downloaded (and were
// not attempted to be downloaded), the first two should be sufficient and the
// payment app should be created.
diff --git a/components/payments/content/secure_payment_confirmation_app_unittest.cc b/components/payments/content/secure_payment_confirmation_app_unittest.cc
index b31cf75e..bc49282 100644
--- a/components/payments/content/secure_payment_confirmation_app_unittest.cc
+++ b/components/payments/content/secure_payment_confirmation_app_unittest.cc
@@ -162,10 +162,11 @@
std::move(credential_id),
/*passkey_browser_binder=*/nullptr,
/*device_supports_browser_bound_keys_in_hardware=*/true,
- url::Origin::Create(GURL("https://merchant.example")), spec_->AsWeakPtr(),
- MakeRequest(), std::move(authenticator),
+ url::Origin::Create(GURL("https://merchant.example")), MakeRequest(),
+ std::move(authenticator),
/*payment_entities_logos=*/{},
/*is_error_dialog=*/false);
+ app.SetTotal(spec_->GetTotal(&app).Clone());
std::vector<uint8_t> expected_bytes =
std::vector<uint8_t>(challenge_bytes_.begin(), challenge_bytes_.end());
@@ -216,10 +217,11 @@
std::move(credential_id),
/*passkey_browser_binder=*/nullptr,
/*device_supports_browser_bound_keys_in_hardware=*/false,
- url::Origin::Create(GURL("https://merchant.example")), spec_->AsWeakPtr(),
- MakeRequest(), std::move(authenticator),
+ url::Origin::Create(GURL("https://merchant.example")), MakeRequest(),
+ std::move(authenticator),
/*payment_entities_logos=*/{},
/*is_error_dialog=*/false);
+ app.SetTotal(spec_->GetTotal(&app).Clone());
ASSERT_NE(nullptr, app.authenticator_for_testing());
@@ -406,10 +408,11 @@
/*payment_instrument_icon=*/std::make_unique<SkBitmap>(), credential_id,
std::move(binder),
GetParam().device_supports_browser_bound_keys_in_hardware,
- url::Origin::Create(GURL("https://merchant.example")), spec_->AsWeakPtr(),
+ url::Origin::Create(GURL("https://merchant.example")),
MakeRequest(GetParam().credential_parameters), std::move(authenticator),
/*payment_entities_logos=*/{},
/*is_error_dialog=*/false);
+ app.SetTotal(spec_->GetTotal(&app).Clone());
app.SetWaitForGetBrowserBoundKeyForTesting(run_loop.QuitClosure());
browser_bound_key_store_->PutFakeKey(FakeBrowserBoundKey(
browser_bound_key_id, public_key_as_cose_key, signature,
@@ -510,10 +513,11 @@
/*credential_id=*/std::vector<uint8_t>(),
/*passkey_browser_binder=*/nullptr,
/*device_supports_browser_bound_keys_in_hardware=*/false,
- url::Origin::Create(GURL("https://merchant.example")), spec_->AsWeakPtr(),
- MakeRequest(), /*authenticator=*/nullptr,
+ url::Origin::Create(GURL("https://merchant.example")), MakeRequest(),
+ /*authenticator=*/nullptr,
/*payment_entities_logos=*/{},
/*is_error_dialog=*/false);
+ app.SetTotal(spec_->GetTotal(&app).Clone());
EXPECT_FALSE(app.HasEnrolledInstrument());
EXPECT_EQ(app.GetId(), "spc");
@@ -531,11 +535,11 @@
/*payment_instrument_icon=*/std::make_unique<SkBitmap>(), credential_id,
/*passkey_browser_binder=*/nullptr,
/*device_supports_browser_bound_keys_in_hardware=*/false,
- url::Origin::Create(GURL("https://merchant.example")), spec_->AsWeakPtr(),
- MakeRequest(),
+ url::Origin::Create(GURL("https://merchant.example")), MakeRequest(),
std::make_unique<webauthn::MockInternalAuthenticator>(web_contents_),
/*payment_entities_logos=*/{},
/*is_error_dialog=*/false);
+ app.SetTotal(spec_->GetTotal(&app).Clone());
EXPECT_TRUE(app.HasEnrolledInstrument());
EXPECT_EQ(app.GetId(), base::Base64Encode(credential_id));
@@ -567,9 +571,10 @@
std::move(credential_id),
/*passkey_browser_binder=*/nullptr,
/*device_supports_browser_bound_keys_in_hardware=*/false,
- url::Origin::Create(GURL("https://merchant.example")), spec_->AsWeakPtr(),
- std::move(request), std::move(authenticator), std::move(logos),
+ url::Origin::Create(GURL("https://merchant.example")), std::move(request),
+ std::move(authenticator), std::move(logos),
/*is_error_dialog=*/false);
+ app.SetTotal(spec_->GetTotal(&app).Clone());
blink::mojom::PaymentOptionsPtr payment_options;
EXPECT_CALL(*mock_authenticator, SetPaymentOptions)
@@ -617,9 +622,10 @@
std::move(credential_id),
/*passkey_browser_binder=*/nullptr,
/*device_supports_browser_bound_keys_in_hardware=*/false,
- url::Origin::Create(GURL("https://merchant.example")), spec_->AsWeakPtr(),
- MakeRequest(), std::move(authenticator), std::move(logos),
+ url::Origin::Create(GURL("https://merchant.example")), MakeRequest(),
+ std::move(authenticator), std::move(logos),
/*is_error_dialog=*/false);
+ app.SetTotal(spec_->GetTotal(&app).Clone());
blink::mojom::PaymentOptionsPtr payment_options;
EXPECT_CALL(*mock_authenticator, SetPaymentOptions)
@@ -656,10 +662,11 @@
std::move(credential_id),
/*passkey_browser_binder=*/nullptr,
/*device_supports_browser_bound_keys_in_hardware=*/false,
- url::Origin::Create(GURL("https://merchant.example")), spec_->AsWeakPtr(),
- MakeRequest(), std::move(authenticator),
+ url::Origin::Create(GURL("https://merchant.example")), MakeRequest(),
+ std::move(authenticator),
/*payment_entities_logos=*/{},
/*is_error_dialog=*/false);
+ app.SetTotal(spec_->GetTotal(&app).Clone());
EXPECT_CALL(*mock_authenticator, GetAssertion(_, _))
.WillOnce(RunOnceCallback<1>(
Original Bug Report
Potential Android SPC WYSIWYS bypass via PaymentDetails modifiers
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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A potential logic flaw in Chrome for Android allows a malicious website to bypass the What-You-See-Is-What-You-Sign (WYSIWYS) guarantee of Secure Payment Confirmation (SPC). The trusted Android SPC dialog displays the raw, unmodified payment total, while the cryptographically-signed clientDataJSON uses a modifier-adjusted total. This discrepancy could allow an attacker to display a benign transaction total to the user while obtaining a signature for a much larger amount.
Affected files:
chrome/android/java/src/org/chromium/chrome/browser/payments/ChromePaymentRequestService.javacomponents/payments/content/android/java/src/org/chromium/components/payments/PaymentRequestSpec.javacomponents/payments/content/secure_payment_confirmation_app.cccomponents/payments/content/payment_request_spec.cc
Estimated timestamp from git blame: 2021-06-30
Description
There is a potential What-You-See-Is-What-You-Sign (WYSIWYS) bypass vulnerability in Chrome for Android’s Secure Payment Confirmation (SPC) flow. The flaw stems from an inconsistency between how the transaction total is retrieved for display on the trusted UI versus how it is retrieved for cryptographic signing.
1. Display Path (Android UI)
In chrome/android/java/src/org/chromium/chrome/browser/payments/ChromePaymentRequestService.java, the SPC bottom-sheet dialog is shown using the raw total obtained from the spec:
mSpcController = new SecurePaymentConfirmationController(
windowAndroid,
...,
mSpec.getRawTotal(), // <-- Raw total
...);
PaymentRequestSpec.java implements getRawTotal() by returning getPaymentDetails().total which refers to the root details_.total object and completely ignores any payment modifiers.
2. Signing Path (C++ WebAuthn / Assertion)
In contrast, the C++ code that prepares the transaction details for cryptographic signing inside components/payments/content/secure_payment_confirmation_app.cc retrieves the total using spec_->GetTotal(this):
authenticator_->SetPaymentOptions(blink::mojom::PaymentOptions::New(
spec_->GetTotal(/*selected_app=*/this)->amount.Clone(), // <-- Modifier-adjusted total
std::move(instrument), request_->payee_name, request_->payee_origin,
std::move(payment_entities_logos),
std::move(browser_bound_public_key)));
PaymentRequestSpec::GetTotal (defined in components/payments/content/payment_request_spec.cc) resolves the total by searching for applicable modifiers matching the selected app’s supported methods (which includes "secure-payment-confirmation"). If a modifier total is found, it overrides the raw total and is serialized into the clientDataJSON WebAuthn payload.
Because of this discrepancy, an attacker can specify a low total (e.g. $1.00) in the raw payment details, and override it with a high total (e.g. $9999.99) in a modifier targeting "secure-payment-confirmation". The user is shown $1.00 on the trusted Chrome UI, but cryptographically signs an assertion for $9999.99.
Suggested/Potential Trigger Path
Note: These are potential steps based on source code analysis; our tooling does not currently have the capability to run code to confirm.
An attacker could theoretically trigger this behavior via the following steps:
- On Chrome for Android, navigate to an attacker-controlled HTTPS page.
- Execute a payment request targeting SPC that provides both a benign total and an SPC modifier specifying a malicious total:
const pr = new PaymentRequest([ { supportedMethods: 'secure-payment-confirmation', data: { rpId: 'rp.example', credentialIds: [id], challenge, payeeOrigin: 'https://merchant.example', instrument: { displayName: 'Card', icon: 'https://.../i.png' } } }], { total: { label: 'Total', amount: { currency: 'USD', value: '1.00' } }, modifiers: [{ supportedMethods: 'secure-payment-confirmation', total: { label: 'Total', amount: { currency: 'USD', value: '9999.99' } } }] } ); const resp = await pr.show(); - The user is presented with Chrome’s trusted SPC bottom sheet showing a total of USD 1.00.
- When the user taps Continue and performs verification, the browser initiates the WebAuthn assertion signature over a
clientDataJSONcontainingpayment.total={"value":"9999.99","currency":"USD"}. - The merchant page receives a valid, cryptographically-signed assertion for the malicious amount.
Suggested Fix
Since SPC is designed as an explicit confirmation tool, payment modifiers (which are designed for dynamic pricing based on payment instruments) are out-of-scope and should not be allowed.
We recommend rejecting the request in ValidateSecurePaymentConfirmationRequest inside components/payments/content/payment_request.cc if details.modifiers contains any modifier targeting "secure-payment-confirmation" (or if modifiers are provided at all). Alternatively, ensure the Android UI retrieves the exact same modifier-adjusted total as the C++ signing path via spec_->GetTotal(app).
Evaluated with Chrome root at commit: b2fea2e31df308d0f04e4ae47def4c4f939ee141
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
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.