Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient policy enforcement in Payments
DescriptionInsufficient policy enforcement in Payments
ComponentPayments
Bug ClassLogic Error
Tracker499003233
Fix commit67e652e50b3a (chromium/src) +308/-57
CISA KEVNot listed
CreditedGoogle
Disclosed2026-07-29

Changed Functions

FunctionChangeNotes
validateSecurePaymentConfirmationRequest
components/payments/content/android/java/src/org/chromium/components/payments/PaymentRequestService.java
modified
if
components/payments/content/android/java/src/org/chromium/components/payments/PaymentRequestService.java
modified
validateSecurePaymentConfirmationRequest
components/payments/content/android/java/src/org/chromium/components/payments/PaymentValidator.java
modified
if
components/payments/content/android/java/src/org/chromium/components/payments/PaymentValidator.java
modified
validateSecurePaymentConfirmationRequest
components/payments/content/android/junit/src/org/chromium/components/payments/test_support/PaymentRequestServiceBuilder.java
modified

Files Changed

  • components/payments/content/android/java/src/org/chromium/components/payments/PaymentRequestService.java
  • components/payments/content/android/java/src/org/chromium/components/payments/PaymentValidator.java
  • components/payments/content/android/junit/src/org/chromium/components/payments/test_support/PaymentRequestServiceBuilder.java
  • components/payments/content/android/payment_validator_android.cc
  • components/payments/content/payment_request.cc
From 67e652e50b3a07ba4a2b2c7d13a1a09ac80e654e Mon Sep 17 00:00:00 2001
From: Stephen McGruer <[email protected]>
Date: Wed, 10 Jun 2026 06:04:19 -0700
Subject: [PATCH] SPC: Disallow WebAuthn extensions in third-party cases

Disallow WebAuthn extensions for Secure Payment Confirmation calls that
are 'third-party' - that is, the caller is not the Relying Party. This
is done to avoid third-party usage of largeBlob and similar data-storing
extensions during the payment ceremony.

This is checked on both the renderer side
(secure_payment_confirmation_helper.cc) and the browser side
(secure_payment_confirmation_validation.cc). The change is put behind a
default-enabled feature flag:
kSecurePaymentConfirmationExtensionsAllowlist.

Bug: 499003233
Change-Id: I02db505f50535d344f734061566199039fa327f9
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7869360
Commit-Queue: Stephen McGruer <[email protected]>
Reviewed-by: Slobodan Pejic <[email protected]>
Auto-Submit: Stephen McGruer <[email protected]>
Reviewed-by: Rick Byers <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1644585}
---

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 11fde37..73bb3c8 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
@@ -298,8 +298,10 @@
          * @return The validation error result.
          */
         default @SecurePaymentConfirmationRequestValidationError int
-                validateSecurePaymentConfirmationRequest(SecurePaymentConfirmationRequest request) {
-            return PaymentValidator.validateSecurePaymentConfirmationRequest(request);
+                validateSecurePaymentConfirmationRequest(
+                        SecurePaymentConfirmationRequest request, Origin initiatorOrigin) {
+            return PaymentValidator.validateSecurePaymentConfirmationRequest(
+                    request, initiatorOrigin);
         }
 
         /**
@@ -541,14 +543,26 @@
                     PaymentErrorReason.INVALID_DATA_FROM_RENDERER);
             return false;
         }
-        if (methodData.containsKey(MethodStrings.SECURE_PAYMENT_CONFIRMATION)
-                && validateSecurePaymentConfirmationRequest(methodData, options)
-                        != SecurePaymentConfirmationRequestValidationError.OK) {
-            mJourneyLogger.setAborted(AbortReason.INVALID_DATA_FROM_RENDERER);
-            disconnectFromClientWithDebugMessage(
-                    ErrorStrings.INVALID_PAYMENT_METHODS_OR_DATA,
-                    PaymentErrorReason.INVALID_DATA_FROM_RENDERER);
-            return false;
+        if (methodData.containsKey(MethodStrings.SECURE_PAYMENT_CONFIRMATION)) {
+            @SecurePaymentConfirmationRequestValidationError
+            int validationResult = validateSecurePaymentConfirmationRequest(methodData, options);
+            if (validationResult != SecurePaymentConfirmationRequestValidationError.OK) {
+                mJourneyLogger.setAborted(AbortReason.INVALID_DATA_FROM_RENDERER);
+
+                if (validationResult
+                        == SecurePaymentConfirmationRequestValidationError
+                                .WEB_AUTHN_EXTENSIONS_NOT_SUPPORTED) {
+                    disconnectFromClientWithDebugMessage(
+                            ErrorStrings.INVALID_PAYMENT_METHODS_OR_DATA,
+                            PaymentErrorReason.NOT_SUPPORTED);
+                } else {
+                    disconnectFromClientWithDebugMessage(
+                            ErrorStrings.INVALID_PAYMENT_METHODS_OR_DATA,
+                            PaymentErrorReason.INVALID_DATA_FROM_RENDERER);
+                }
+
+                return false;
+            }
         }
         methodData = Collections.unmodifiableMap(methodData);
 
@@ -627,7 +641,7 @@
 
         // Delegate to the native implementation for final validation.
         return mDelegate.validateSecurePaymentConfirmationRequest(
-                spcMethodData.securePaymentConfirmation);
+                spcMethodData.securePaymentConfirmation, mPaymentRequestSecurityOrigin);
     }
 
     private void startPaymentAppService() {
diff --git a/components/payments/content/android/java/src/org/chromium/components/payments/PaymentValidator.java b/components/payments/content/android/java/src/org/chromium/components/payments/PaymentValidator.java
index 74ce867..bbceaeca 100644
--- a/components/payments/content/android/java/src/org/chromium/components/payments/PaymentValidator.java
+++ b/components/payments/content/android/java/src/org/chromium/components/payments/PaymentValidator.java
@@ -5,12 +5,14 @@
 package org.chromium.components.payments;
 
 import org.jni_zero.JNINamespace;
+import org.jni_zero.JniType;
 import org.jni_zero.NativeMethods;
 
 import org.chromium.build.annotations.NullMarked;
 import org.chromium.payments.mojom.PaymentDetails;
 import org.chromium.payments.mojom.PaymentValidationErrors;
 import org.chromium.payments.mojom.SecurePaymentConfirmationRequest;
+import org.chromium.url.Origin;
 
 import java.nio.ByteBuffer;
 
@@ -33,12 +35,14 @@
     }
 
     public static @SecurePaymentConfirmationRequestValidationError int
-            validateSecurePaymentConfirmationRequest(SecurePaymentConfirmationRequest request) {
-        if (request == null) {
+            validateSecurePaymentConfirmationRequest(
+                    SecurePaymentConfirmationRequest request, Origin initiatorOrigin) {
+        if (request == null || initiatorOrigin == null) {
             return SecurePaymentConfirmationRequestValidationError.INTERNAL_ERROR;
         }
         return PaymentValidatorJni.get()
-                .validateSecurePaymentConfirmationRequestAndroid(request.serialize());
+                .validateSecurePaymentConfirmationRequestAndroid(
+                        request.serialize(), initiatorOrigin);
     }
 
     @NativeMethods
@@ -48,6 +52,7 @@
         boolean validatePaymentValidationErrorsAndroid(ByteBuffer buffer);
 
         @SecurePaymentConfirmationRequestValidationError
-        int validateSecurePaymentConfirmationRequestAndroid(ByteBuffer buffer);
+        int validateSecurePaymentConfirmationRequestAndroid(
+                ByteBuffer buffer, @JniType("url::Origin") Origin initiatorOrigin);
     }
 }
diff --git a/components/payments/content/android/junit/src/org/chromium/components/payments/test_support/PaymentRequestServiceBuilder.java b/components/payments/content/android/junit/src/org/chromium/components/payments/test_support/PaymentRequestServiceBuilder.java
index 1115410a..13593a37 100644
--- a/components/payments/content/android/junit/src/org/chromium/components/payments/test_support/PaymentRequestServiceBuilder.java
+++ b/components/payments/content/android/junit/src/org/chromium/components/payments/test_support/PaymentRequestServiceBuilder.java
@@ -170,7 +170,8 @@
 
     @Override
     public @SecurePaymentConfirmationRequestValidationError int
-            validateSecurePaymentConfirmationRequest(SecurePaymentConfirmationRequest request) {
+            validateSecurePaymentConfirmationRequest(
+                    SecurePaymentConfirmationRequest request, Origin initiatorOrigin) {
         return mIsSecurePaymentConfirmationRequestValid
                 ? SecurePaymentConfirmationRequestValidationError.OK
                 : SecurePaymentConfirmationRequestValidationError.CREDENTIAL_IDS_REQUIRED;
diff --git a/components/payments/content/android/payment_validator_android.cc b/components/payments/content/android/payment_validator_android.cc
index 45aa98f..603dc59 100644
--- a/components/payments/content/android/payment_validator_android.cc
+++ b/components/payments/content/android/payment_validator_android.cc
@@ -17,6 +17,7 @@
 #include "components/payments/core/payment_details_validation.h"
 #include "components/payments/core/payments_validators.h"
 #include "third_party/blink/public/mojom/payments/payment_request.mojom.h"
+#include "url/origin.h"
 
 // Must come after all headers that specialize FromJniType() / ToJniType().
 #include "components/payments/content/android/jni_headers/PaymentValidator_jni.h"
@@ -53,7 +54,8 @@
 static jint
 JNI_PaymentValidator_ValidateSecurePaymentConfirmationRequestAndroid(
     JNIEnv* env,
-    const base::android::JavaRef<jobject>& buffer) {
+    const base::android::JavaRef<jobject>& buffer,
+    const url::Origin& initiator_origin) {
   mojom::SecurePaymentConfirmationRequestPtr request;
   auto span = base::android::JavaByteBufferToSpan(env, buffer);
   if (!mojom::SecurePaymentConfirmationRequest::Deserialize(
@@ -61,7 +63,8 @@
     return static_cast<jint>(
         SecurePaymentConfirmationRequestValidationError::kInternalError);
   }
-  return static_cast<jint>(IsValidSecurePaymentConfirmationRequest(request));
+  return static_cast<jint>(
+      IsValidSecurePaymentConfirmationRequest(request, initiator_origin));
 }
 
 }  // namespace payments
diff --git a/components/payments/content/payment_request.cc b/components/payments/content/payment_request.cc
index 05c75a8..847c687 100644
--- a/components/payments/content/payment_request.cc
+++ b/components/payments/content/payment_request.cc
@@ -77,7 +77,8 @@
 SecurePaymentConfirmationRequestValidationError
 ValidateSecurePaymentConfirmationRequest(
     const std::vector<mojom::PaymentMethodDataPtr>& method_data,
-    const mojom::PaymentOptionsPtr& options) {
+    const mojom::PaymentOptionsPtr& options,
+    const url::Origin& initiator_origin) {
   CHECK_GT(method_data.size(), 0u);
 
   if (!base::FeatureList::IsEnabled(::features::kSecurePaymentConfirmation)) {
@@ -116,7 +117,7 @@
   }
 
   return IsValidSecurePaymentConfirmationRequest(
-      method_data_entry->secure_payment_confirmation);
+      method_data_entry->secure_payment_confirmation, initiator_origin);
 }
Loading diff…

Regression Test / PoC

shipped with the fix
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 46e0a24..318667c 100644
--- a/components/payments/content/secure_payment_confirmation_app_factory_unittest.cc
+++ b/components/payments/content/secure_payment_confirmation_app_factory_unittest.cc
@@ -204,6 +204,9 @@
       CreateSecurePaymentConfirmationRequest();
   auto mock_delegate = std::make_unique<MockPaymentAppFactoryDelegate>(
       web_contents_, std::move(method_data));
+  url::Origin caller_origin = url::Origin::Create(GURL("https://site.example"));
+  EXPECT_CALL(*mock_delegate, GetFrameSecurityOrigin())
+      .WillRepeatedly(ReturnRef(caller_origin));
 
   EXPECT_CALL(*mock_delegate, OnPaymentAppCreationError(_, _)).Times(0);
   secure_payment_confirmation_app_factory_->Create(mock_delegate->GetWeakPtr());
@@ -222,7 +225,7 @@
       CreateMockDelegate(std::move(method_data));
   url::Origin caller_origin = url::Origin::Create(GURL("https://rp.example"));
   EXPECT_CALL(*mock_delegate, GetFrameSecurityOrigin())
-      .WillOnce(ReturnRef(caller_origin));
+      .WillRepeatedly(ReturnRef(caller_origin));
 
   EXPECT_CALL(*mock_credential_finder_, GetMatchingCredentials)
       .WillOnce(RunOnceCallback<5>(GetMatchingCredentialsIsUnsupported()));
@@ -255,7 +258,7 @@
   std::unique_ptr<MockPaymentAppFactoryDelegate> mock_delegate =
       CreateMockDelegate(std::move(method_data));
   EXPECT_CALL(*mock_delegate, GetFrameSecurityOrigin())
-      .WillOnce(ReturnRef(caller_origin));
+      .WillRepeatedly(ReturnRef(caller_origin));
 
   std::unique_ptr<PaymentApp> secure_payment_confirmation_app;
   EXPECT_CALL(*mock_delegate, OnPaymentAppCreated(_))
@@ -296,7 +299,7 @@
   std::unique_ptr<MockPaymentAppFactoryDelegate> mock_delegate =
       CreateMockDelegate(std::move(method_data));
   EXPECT_CALL(*mock_delegate, GetFrameSecurityOrigin())
-      .WillOnce(ReturnRef(caller_origin));
+      .WillRepeatedly(ReturnRef(caller_origin));
 
   EXPECT_CALL(*mock_delegate, OnPaymentAppCreated(_)).Times(0);
   EXPECT_CALL(*mock_delegate, OnDoneCreatingPaymentApps());
@@ -332,7 +335,7 @@
   std::unique_ptr<MockPaymentAppFactoryDelegate> mock_delegate =
       CreateMockDelegate(std::move(method_data));
   EXPECT_CALL(*mock_delegate, GetFrameSecurityOrigin())
-      .WillOnce(ReturnRef(caller_origin));
+      .WillRepeatedly(ReturnRef(caller_origin));
 
   // Ensure that the SecurePaymentConfirmationAppFactory extracts and passes in
   // the correct set of credentials, relying party id, and caller origin. The
@@ -420,7 +423,7 @@
       CreateMockDelegate(std::move(method_data));
   url::Origin caller_origin = url::Origin::Create(GURL("https://rp.example"));
   EXPECT_CALL(*mock_delegate, GetFrameSecurityOrigin())
-      .WillOnce(ReturnRef(caller_origin));
+      .WillRepeatedly(ReturnRef(caller_origin));
 
   std::unique_ptr<PaymentApp> created_payment_app;
   EXPECT_CALL(*mock_delegate, OnPaymentAppCreated(_))
@@ -472,7 +475,7 @@
   std::unique_ptr<MockPaymentAppFactoryDelegate> mock_delegate =
       CreateMockDelegate(std::move(method_data));
   EXPECT_CALL(*mock_delegate, GetFrameSecurityOrigin())
-      .WillOnce(ReturnRef(caller_origin));
+      .WillRepeatedly(ReturnRef(caller_origin));
   std::unique_ptr<PaymentApp> secure_payment_confirmation_app;
   EXPECT_CALL(*mock_delegate, OnPaymentAppCreated(_))
       .WillOnce(MoveArg<0>(&secure_payment_confirmation_app));
@@ -517,6 +520,9 @@
 
   auto mock_delegate = std::make_unique<MockPaymentAppFactoryDelegate>(
       web_contents_, 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>();
@@ -555,7 +561,7 @@
       CreateMockDelegate(std::move(method_data));
   url::Origin caller_origin = url::Origin::Create(GURL("https://site.example"));
   EXPECT_CALL(*mock_delegate, GetFrameSecurityOrigin())
-      .WillOnce(ReturnRef(caller_origin));
+      .WillRepeatedly(ReturnRef(caller_origin));
   std::unique_ptr<PaymentApp> secure_payment_confirmation_app;
   EXPECT_CALL(*mock_delegate, OnPaymentAppCreated(_))
       .WillOnce(MoveArg<0>(&secure_payment_confirmation_app));
@@ -597,7 +603,7 @@
   std::unique_ptr<MockPaymentAppFactoryDelegate> mock_delegate =
       CreateMockDelegate(std::move(method_data));
   EXPECT_CALL(*mock_delegate, GetFrameSecurityOrigin())
-      .WillOnce(ReturnRef(caller_origin));
+      .WillRepeatedly(ReturnRef(caller_origin));
 
   EXPECT_CALL(*mock_delegate, OnPaymentAppCreated(_)).Times(0);
   EXPECT_CALL(*mock_delegate, OnDoneCreatingPaymentApps());
diff --git a/components/payments/content/secure_payment_confirmation_validation_unittest.cc b/components/payments/content/secure_payment_confirmation_validation_unittest.cc
index c986f3d..d94d25d 100644
--- a/components/payments/content/secure_payment_confirmation_validation_unittest.cc
+++ b/components/payments/content/secure_payment_confirmation_validation_unittest.cc
@@ -10,6 +10,7 @@
 #include "components/payments/core/native_error_strings.h"
 #include "testing/gtest/include/gtest/gtest.h"
 #include "third_party/blink/public/mojom/payments/payment_request.mojom.h"
+#include "third_party/blink/public/mojom/webauthn/authenticator.mojom.h"
 #include "url/gurl.h"
 #include "url/origin.h"
 
@@ -35,7 +36,8 @@
 
 TEST(SecurePaymentConfirmationValidationTest, IsValidRequest) {
   auto request = CreateValidRequest();
-  EXPECT_EQ(IsValidSecurePaymentConfirmationRequest(request),
+  EXPECT_EQ(payments::IsValidSecurePaymentConfirmationRequest(
+                request, url::Origin::Create(GURL("https://rp.example"))),
             SecurePaymentConfirmationRequestValidationError::kOk);
 }
 
@@ -43,7 +45,8 @@
   auto request = CreateValidRequest();
   request->credential_ids.clear();
   EXPECT_EQ(
-      IsValidSecurePaymentConfirmationRequest(request),
+      payments::IsValidSecurePaymentConfirmationRequest(
+          request, url::Origin::Create(GURL("https://rp.example"))),
       SecurePaymentConfirmationRequestValidationError::kCredentialIdsRequired);
 }
 
@@ -51,7 +54,8 @@
   auto request = CreateValidRequest();
   request->credential_ids.emplace_back();
   EXPECT_EQ(
-      IsValidSecurePaymentConfirmationRequest(request),
+      payments::IsValidSecurePaymentConfirmationRequest(
+          request, url::Origin::Create(GURL("https://rp.example"))),
       SecurePaymentConfirmationRequestValidationError::kCredentialIdsRequired);
 }
 
@@ -59,14 +63,16 @@
   auto request = CreateValidRequest();
   request->challenge.clear();
   EXPECT_EQ(
-      IsValidSecurePaymentConfirmationRequest(request),
+      payments::IsValidSecurePaymentConfirmationRequest(
+          request, url::Origin::Create(GURL("https://rp.example"))),
       SecurePaymentConfirmationRequestValidationError::kChallengeRequired);
 }
 
 TEST(SecurePaymentConfirmationValidationTest, EmptyDisplayName) {
   auto request = CreateValidRequest();
   request->instrument->display_name.clear();
-  EXPECT_EQ(IsValidSecurePaymentConfirmationRequest(request),
+  EXPECT_EQ(payments::IsValidSecurePaymentConfirmationRequest(
+                request, url::Origin::Create(GURL("https://rp.example"))),
             SecurePaymentConfirmationRequestValidationError::
                 kInstrumentDisplayNameRequired);
 }
@@ -74,7 +80,8 @@
 TEST(SecurePaymentConfirmationValidationTest, EmptyInstrumentIcon) {
   auto request = CreateValidRequest();
   request->instrument->icon = GURL();
-  EXPECT_EQ(IsValidSecurePaymentConfirmationRequest(request),
+  EXPECT_EQ(payments::IsValidSecurePaymentConfirmationRequest(
+                request, url::Origin::Create(GURL("https://rp.example"))),
             SecurePaymentConfirmationRequestValidationError::
                 kValidInstrumentIconRequired);
 }
@@ -82,7 +89,8 @@
 TEST(SecurePaymentConfirmationValidationTest, InvalidInstrumentIcon) {
   auto request = CreateValidRequest();
   request->instrument->icon = GURL("not-a-url");
-  EXPECT_EQ(IsValidSecurePaymentConfirmationRequest(request),
+  EXPECT_EQ(payments::IsValidSecurePaymentConfirmationRequest(
+                request, url::Origin::Create(GURL("https://rp.example"))),
             SecurePaymentConfirmationRequestValidationError::
                 kValidInstrumentIconRequired);
 }
@@ -90,7 +98,8 @@
 TEST(SecurePaymentConfirmationValidationTest, NonUtf8InstrumentDetails) {
   auto request = CreateValidRequest();
   request->instrument->details = {'\xEF', '\xB7', '\xAF'};
-  EXPECT_EQ(IsValidSecurePaymentConfirmationRequest(request),
+  EXPECT_EQ(payments::IsValidSecurePaymentConfirmationRequest(
+                request, url::Origin::Create(GURL("https://rp.example"))),
             SecurePaymentConfirmationRequestValidationError::
                 kNonUtf8InstrumentDetailsString);
 }
@@ -98,7 +107,8 @@
 TEST(SecurePaymentConfirmationValidationTest, EmptyInstrumentDetails) {
   auto request = CreateValidRequest();
   request->instrument->details = "";
-  EXPECT_EQ(IsValidSecurePaymentConfirmationRequest(request),
+  EXPECT_EQ(payments::IsValidSecurePaymentConfirmationRequest(
+                request, url::Origin::Create(GURL("https://rp.example"))),
             SecurePaymentConfirmationRequestValidationError::
                 kEmptyInstrumentDetailsString);
 }
@@ -106,7 +116,8 @@
 TEST(SecurePaymentConfirmationValidationTest, TooLongInstrumentDetails) {
   auto request = CreateValidRequest();
   request->instrument->details = std::string(4097, '.');
-  EXPECT_EQ(IsValidSecurePaymentConfirmationRequest(request),
+  EXPECT_EQ(payments::IsValidSecurePaymentConfirmationRequest(
+                request, url::Origin::Create(GURL("https://rp.example"))),
             SecurePaymentConfirmationRequestValidationError::
                 kTooLongInstrumentDetailsString);
 }
@@ -124,7 +135,8 @@
   for (const std::string& rp_id : invalid_cases) {
     auto request = CreateValidRequest();
     request->rp_id = rp_id;
-    EXPECT_EQ(IsValidSecurePaymentConfirmationRequest(request),
+    EXPECT_EQ(payments::IsValidSecurePaymentConfirmationRequest(
+                  request, url::Origin::Create(GURL("https://rp.example"))),
               SecurePaymentConfirmationRequestValidationError::kRpIdRequired)
         << "rp_id: " << rp_id;
   }
@@ -134,7 +146,8 @@
   auto request = CreateValidRequest();
   request->payee_name.reset();
   request->payee_origin.reset();
-  EXPECT_EQ(IsValidSecurePaymentConfirmationRequest(request),
+  EXPECT_EQ(payments::IsValidSecurePaymentConfirmationRequest(
+                request, url::Origin::Create(GURL("https://rp.example"))),
             SecurePaymentConfirmationRequestValidationError::
                 kPayeeOriginOrPayeeNameRequired);
 }
@@ -142,7 +155,8 @@
 TEST(SecurePaymentConfirmationValidationTest, EmptyPayeeName) {
   auto request = CreateValidRequest();
   request->payee_name = "";
-  EXPECT_EQ(IsValidSecurePaymentConfirmationRequest(request),
+  EXPECT_EQ(payments::IsValidSecurePaymentConfirmationRequest(
+                request, url::Origin::Create(GURL("https://rp.example"))),
             SecurePaymentConfirmationRequestValidationError::
                 kPayeeOriginOrPayeeNameRequired);
 }
@@ -151,14 +165,16 @@
   auto request = CreateValidRequest();
   request->payee_origin = url::Origin::Create(GURL("http://site.example"));
   EXPECT_EQ(
-      IsValidSecurePaymentConfirmationRequest(request),
+      payments::IsValidSecurePaymentConfirmationRequest(
+          request, url::Origin::Create(GURL("https://rp.example"))),
       SecurePaymentConfirmationRequestValidationError::kPayeeOriginMustBeHttps);
 }
 
 TEST(SecurePaymentConfirmationValidationTest, NullPaymentEntityLogo) {
   auto request = CreateValidRequest();
   request->payment_entities_logos.push_back(nullptr);
-  EXPECT_EQ(IsValidSecurePaymentConfirmationRequest(request),
+  EXPECT_EQ(payments::IsValidSecurePaymentConfirmationRequest(
+                request, url::Origin::Create(GURL("https://rp.example"))),
             SecurePaymentConfirmationRequestValidationError::
                 kNonNullPaymentEntityLogoRequired);
 }
@@ -168,7 +184,8 @@
   request->payment_entities_logos.push_back(
       mojom::PaymentEntityLogo::New(GURL(), "Label"));
   EXPECT_EQ(
-      IsValidSecurePaymentConfirmationRequest(request),
+      payments::IsValidSecurePaymentConfirmationRequest(
+          request, url::Origin::Create(GURL("https://rp.example"))),
       SecurePaymentConfirmationRequestValidationError::kValidLogoUrlRequired);
 }
 
@@ -177,7 +194,8 @@
   request->payment_entities_logos.push_back(
       mojom::PaymentEntityLogo::New(GURL("thisisnotaurl"), "Label"));
   EXPECT_EQ(
-      IsValidSecurePaymentConfirmationRequest(request),
+      payments::IsValidSecurePaymentConfirmationRequest(
+          request, url::Origin::Create(GURL("https://rp.example"))),
       SecurePaymentConfirmationRequestValidationError::kValidLogoUrlRequired);
 }
 
@@ -186,7 +204,8 @@
   auto request = CreateValidRequest();
   request->payment_entities_logos.push_back(mojom::PaymentEntityLogo::New(
       GURL("blob://blob.foo.com/logo.png"), "Label"));
-  EXPECT_EQ(IsValidSecurePaymentConfirmationRequest(request),
+  EXPECT_EQ(payments::IsValidSecurePaymentConfirmationRequest(
+                request, url::Origin::Create(GURL("https://rp.example"))),
             SecurePaymentConfirmationRequestValidationError::
                 kValidLogoUrlSchemeRequired);
 }
@@ -196,9 +215,53 @@
   request->payment_entities_logos.push_back(mojom::PaymentEntityLogo::New(
       GURL("https://entity.example/icon.png"), ""));
   EXPECT_EQ(
-      IsValidSecurePaymentConfirmationRequest(request),
+      payments::IsValidSecurePaymentConfirmationRequest(
+          request, url::Origin::Create(GURL("https://rp.example"))),
       SecurePaymentConfirmationRequestValidationError::kLogoLabelRequired);
 }
 
+TEST(SecurePaymentConfirmationValidationTest,
+     WebAuthnExtensionsAllowedForFirstParty) {
+  auto request = CreateValidRequest();
+  request->extensions =
... (truncated)
Loading diff…

Original Bug Report

reported by [email protected]

Cross-origin leak of WebAuthn secrets (PRF, largeBlob) via Secure Payment Confirmation

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 security team.

Overview: Secure Payment Confirmation (SPC) intentionally bypasses the standard WebAuthn registrable-domain constraint to allow third-party assertions. However, it fails to restrict the use of sensitive WebAuthn extensions like PRF and largeBlob during this bypass. This potentially allows a malicious origin to extract cryptographic secrets bound to a victim Relying Party (RP) through a deceptive user prompt.

Affected files:

  • components/payments/content/secure_payment_confirmation_app.cc
  • content/browser/webauth/authenticator_common_impl.cc
  • content/browser/webauth/webauth_request_security_checker.cc
  • components/payments/content/secure_payment_confirmation_service.cc

Estimated timestamp from git blame: 2025-09-19

Vulnerability Details

Secure Payment Confirmation (SPC) allows a third-party origin (such as a merchant or payment provider) to request an assertion for a credential owned by a different Relying Party (RP). To support this, WebAuthRequestSecurityChecker::ValidateDomainAndRelyingPartyID explicitly bypasses the standard WebAuthn RP ID validation when the request type is kGetPaymentCredentialAssertion.

However, the system fails to restrict the use of sensitive WebAuthn extensions during this cross-origin bypass. When an SPC request is made, SecurePaymentConfirmationApp::InvokePaymentApp blindly clones the attacker-supplied extensions dictionary and passes it to AuthenticatorCommonImpl. Because AuthenticatorCommonImpl lacks logic to filter extensions based on the kGetPaymentCredentialAssertion request type, it forwards requests for extensions like prf, large_blob_read, and get_cred_blob directly to the platform authenticator.

Once the authenticator evaluates the extensions using the victim’s credential, the results (e.g., the HMAC-secret derived from the PRF) are returned to the browser. SecurePaymentConfirmationApp::SetAppSpecificResponseFields then explicitly clones these unverified extension outputs back into the PaymentResponse Mojo struct, returning them to the attacker’s origin.

On Desktop, this issue is exacerbated by a missing origin check in the Mojo interface. SecurePaymentConfirmationService::StorePaymentCredential allows a compromised renderer to poison the local database by binding an arbitrary credential_id to an arbitrary rp_id because it fails to verify that the rp_id matches the caller’s origin. This permits an attacker to target any WebAuthn credential on the device, rather than just those opted into SPC.

Potential Attack Steps

(Note: These are suggested steps based on source code analysis; a functional exploit has not yet been executed in a live environment.)

  1. DB Poisoning (Desktop only): An attacker with a compromised renderer calls the StorePaymentCredential Mojo method, providing an arbitrary credential_id and setting the rp_id to a victim domain (e.g., victim.com). The browser stores this in the local DB without validating the origin.
  2. Malicious Request: The attacker runs JavaScript using the PaymentRequest API to initiate an SPC request. They specify rpId: 'victim.com', request sensitive WebAuthn extensions (prf, largeBlob), and provide a deceptive payeeName (e.g., “Security Verification”) while omitting showOptOut to hide the victim RP ID from the UI.
  3. Bypass and Forwarding: The browser honors the SPC request, bypasses the RP ID check, and forwards the extension requests to the platform authenticator.
  4. Deceptive UI: The native SPC prompt appears, displaying the deceptive payee name and hiding the victim’s RP ID. The user clicks “Verify”.
  5. Exfiltration: The authenticator evaluates the PRF or largeBlob data for victim.com. The browser blindly returns these results in the PaymentResponse, allowing the attacker’s JavaScript to read the cross-origin secrets.

Suggested Fix

  1. Restrict Extensions: In AuthenticatorCommonImpl::ContinueGetAssertionAfterRpIdCheck, explicitly deny or filter out sensitive extensions (prf, large_blob_read, large_blob_write, get_cred_blob) if the request is an SPC assertion (request_type == RequestType::kGetPaymentCredentialAssertion). Alternatively, filter them in SecurePaymentConfirmationApp::InvokePaymentApp.
  2. Enforce Origin Validation: In SecurePaymentConfirmationService::StorePaymentCredential, verify that the rp_id provided by the renderer is a valid registrable domain suffix of the RenderFrameHost’s last committed origin to prevent database poisoning by a compromised renderer.

Evaluated with Chrome root at commit: ff3d2b74fa39431785bd60e51463b08fcc71ee33


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