CVE-2026-102310
Overview
Background
- Secure Payment Confirmation (SPC)
- a Web Payments feature that lets a site enroll and later assert WebAuthn-style payment credentials, implemented browser-side by
payments::SecurePaymentConfirmationService. - RP ID (Relying Party ID)
- the domain string a credential is scoped to; it must belong to (or be a registrable suffix of) the calling origin, so one origin cannot create credentials for another.
- `WebAuthRequestSecurityChecker`
- the browser-side component that validates that a renderer-supplied RP ID is permitted for the caller’s origin (via
ValidateDomainAndRelyingPartyID).
Root Cause Analysis
e3ba22448968. An earlier analysis was withdrawn because it had been written against a commit that was not the security fix (f8e6432922c9).SecurePaymentConfirmationService::StorePaymentCredential is a Mojo method callable from the renderer with a renderer-chosen rp_id (secure_payment_confirmation_service.cc:139-143). In the pre-fix code the only entry gating was web_data_service_ presence, content::IsFrameAllowedToUseSecurePaymentConfirmation(&render_frame_host()), and non-empty credential_id/rp_id/user_id (secure_payment_confirmation_service.cc:144-151); nothing tied rp_id to the caller’s origin. If features::kSecurePaymentConfirmationUseCredentialStoreAPIs is enabled, the method returns SUCCESS without storing anything (secure_payment_confirmation_service.cc:158-162, “the credential information will already have been stored during creation”); only with that feature disabled does it store the credential directly, constructing SecurePaymentConfirmationCredential(credential_id, rp_id, user_id) and calling web_data_service_->AddSecurePaymentConfirmationCredential(...) (secure_payment_confirmation_service.cc:164-173). The invariant that a credential’s RP ID must be authorized for the calling origin was therefore never enforced on the store path, allowing a confused-deputy / cross-origin credential write.
The fix splits the flow: StorePaymentCredential now invokes render_frame_host().GetWebAuthRequestSecurityChecker()->ValidateDomainAndRelyingPartyID(origin(), rp_id, ...kMakePaymentCredential..., BindOnce(&...::ContinueStorePaymentCredentialAfterRpIdCheck,...)) (diff, added lines) and the new continuation rejects any rp_id_validation_result != blink::mojom::AuthenticatorStatus::SUCCESS by reporting a bad message and calling ResetAndDeleteThis(), so the credential is not stored. The check now runs before the feature branch, so a mismatched rp_id is rejected regardless of the feature state. A guard remote_validation_ || ... is added to the entry check (diff at pre-fix line 144) to reject re-entrant calls while a validation is in flight, and a weak_this check protects against the validator invoking its callback synchronously and deleting this.
ValidateDomainAndRelyingPartyID authorization check and refuses to persist (and kills the pipe) when it fails.Attack Path
- Compromised renderer
A compromised renderer in an allowed frame obtains the
mojom::SecurePaymentConfirmationServiceinterface. (inferred: the material does not show ordinary script reachingStorePaymentCredentialwith an arbitraryrp_id, and the fix treats a mismatch as a Mojo bad message,"Invalid RP ID in StorePaymentCredential", which implies a compromised-renderer threat model.) - Forge the RP ID
It calls
StorePaymentCredentialsupplying arp_idfor a domain it does not control, with a non-emptycredential_idanduser_id. - Unauthorized store
Pre-fix, all gating checks pass and, when
kSecurePaymentConfirmationUseCredentialStoreAPIsis disabled (otherwise the call returns at:158-162without storing),AddSecurePaymentConfirmationCredentialpersists a credential scoped to the victim RP ID (secure_payment_confirmation_service.cc:164-173). - Confused deputy A credential attributable to another relying party now exists in the browser’s store, i.e. cross-origin credential corruption. (inferred: downstream payment-authentication consequences are not shown in the material.)
Impact Assessment
kSecurePaymentConfirmationUseCredentialStoreAPIs disabled, cause the browser to store an SPC payment credential under a relying-party ID not authorized for its origin, a missing-authorization / confused-deputy write into the browser-side credential store. The advisory rates this low severity and reports no in-the-wild exploitation. Precondition is a context already permitted to use SPC (IsFrameAllowedToUseSecurePaymentConfirmation).Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcomponents/payments/content/secure_payment_confirmation_service.cc |
modified | |
SecurePaymentConfirmationServiceTestcomponents/payments/content/secure_payment_confirmation_service_unittest.cc |
modified |
Files Changed
components/payments/content/secure_payment_confirmation_service.cccomponents/payments/content/secure_payment_confirmation_service.hcomponents/payments/content/secure_payment_confirmation_service_unittest.cc
Audit Directions
- Renderer-supplied identifiersFlag any browser-side handler that persists or acts on a renderer-provided RP ID, origin, or domain without validating it against
origin()/ the caller’s security context. - Split-callback authorizationWhen a check is made asynchronous via a callback (e.g.
ValidateDomainAndRelyingPartyID), verify the callback both enforces the failure path and guards against synchronous invocation deletingthis(theweak_this/ WeakPtr pattern). - DocumentService lifetimeFor
content::DocumentServicesubclasses, confirm failure paths callResetAndDeleteThis()and that in-flight operations cannot be re-entered (theremote_validation_guard).
Patch
diff --git a/components/payments/content/secure_payment_confirmation_service.cc b/components/payments/content/secure_payment_confirmation_service.cc
index 398922d..01a45f0 100644
--- a/components/payments/content/secure_payment_confirmation_service.cc
+++ b/components/payments/content/secure_payment_confirmation_service.cc
@@ -22,8 +22,10 @@
#include "components/webauthn/core/browser/internal_authenticator.h"
#include "content/public/browser/browser_context.h"
#include "content/public/browser/navigation_handle.h"
+#include "content/public/browser/render_frame_host.h"
#include "content/public/browser/secure_payment_confirmation_utils.h"
#include "content/public/browser/web_contents.h"
+#include "content/public/browser/webauth_request_security_checker.h"
#include "content/public/common/content_features.h"
#include "crypto/random.h"
#include "services/network/public/mojom/permissions_policy/permissions_policy_feature.mojom-shared.h"
@@ -141,7 +143,7 @@
const std::string& rp_id,
const std::vector<uint8_t>& user_id,
StorePaymentCredentialCallback callback) {
- if (!web_data_service_ ||
+ if (remote_validation_ || !web_data_service_ ||
!content::IsFrameAllowedToUseSecurePaymentConfirmation(
&render_frame_host()) ||
credential_id.empty() || rp_id.empty() || user_id.empty()) {
@@ -150,6 +152,49 @@
return;
}
+ base::WeakPtr<SecurePaymentConfirmationService> weak_this =
+ weak_ptr_factory_.GetWeakPtr();
+
+ auto remote_validation =
+ render_frame_host()
+ .GetWebAuthRequestSecurityChecker()
+ ->ValidateDomainAndRelyingPartyID(
+ origin(), rp_id,
+ content::WebAuthRequestSecurityChecker::RequestType::
+ kMakePaymentCredential,
+ /*remote_desktop_client_override_origin=*/std::nullopt,
+ base::BindOnce(&SecurePaymentConfirmationService::
+ ContinueStorePaymentCredentialAfterRpIdCheck,
+ weak_this, mojo::GetBadMessageCallback(),
+ credential_id, rp_id, user_id,
+ std::move(callback)));
+
+ // ValidateDomainAndRelyingPartyID might run the callback synchronously.
+ // If validation fails, the callback will call `ResetAndDeleteThis()` and
+ // delete `this`. We must check `weak_this` to avoid a UAF when storing the
+ // returned validation handle.
+ if (weak_this) {
+ remote_validation_ = std::move(remote_validation);
+ }
+}
+
+void SecurePaymentConfirmationService::
+ ContinueStorePaymentCredentialAfterRpIdCheck(
+ mojo::ReportBadMessageCallback bad_message_callback,
+ std::vector<uint8_t> credential_id,
+ const std::string& rp_id,
+ std::vector<uint8_t> user_id,
+ StorePaymentCredentialCallback callback,
+ blink::mojom::AuthenticatorStatus rp_id_validation_result) {
+ remote_validation_.reset();
+ // If the RP ID check failed, we cannot store the credential.
+ if (rp_id_validation_result != blink::mojom::AuthenticatorStatus::SUCCESS) {
+ std::move(bad_message_callback)
+ .Run("Invalid RP ID in StorePaymentCredential");
+ ResetAndDeleteThis();
+ return;
+ }
+
RecordFirstSystemPromptResult(
SecurePaymentConfirmationEnrollSystemPromptResult::kAccepted);
@@ -162,8 +207,8 @@
}
web_data_service_->AddSecurePaymentConfirmationCredential(
- std::make_unique<SecurePaymentConfirmationCredential>(credential_id,
- rp_id, user_id),
+ std::make_unique<SecurePaymentConfirmationCredential>(
+ std::move(credential_id), rp_id, std::move(user_id)),
base::BindOnce([](WebDataServiceBase::Handle h,
std::unique_ptr<WDTypedResult> result) {
return result && static_cast<WDResult<bool>*>(result.get())->GetValue()
diff --git a/components/payments/content/secure_payment_confirmation_service.h b/components/payments/content/secure_payment_confirmation_service.h
index 6734b41c..5c2d2301 100644
--- a/components/payments/content/secure_payment_confirmation_service.h
+++ b/components/payments/content/secure_payment_confirmation_service.h
@@ -15,10 +15,12 @@
#include "base/memory/weak_ptr.h"
#include "components/payments/content/browser_binding/passkey_browser_binder.h"
#include "components/payments/core/secure_payment_confirmation_metrics.h"
+#include "components/webauthn/core/browser/remote_validation.h"
#include "components/webdata/common/web_data_service_base.h"
#include "components/webdata/common/web_data_service_consumer.h"
#include "content/public/browser/document_service.h"
#include "content/public/browser/global_routing_id.h"
+#include "mojo/public/cpp/bindings/message.h"
#include "mojo/public/cpp/bindings/pending_receiver.h"
#include "third_party/blink/public/mojom/payments/secure_payment_confirmation_service.mojom.h"
@@ -86,6 +88,16 @@
scoped_refptr<BrowserBoundKeyStore> browser_bound_key_store);
private:
+ // Called after the RpId check has been completed, to continue the process of
+ // storing the payment credential.
+ void ContinueStorePaymentCredentialAfterRpIdCheck(
+ mojo::ReportBadMessageCallback bad_message_callback,
+ std::vector<uint8_t> credential_id,
+ const std::string& rp_id,
+ std::vector<uint8_t> user_id,
+ StorePaymentCredentialCallback callback,
+ blink::mojom::AuthenticatorStatus rp_id_validation_result);
+
// MakeCredentialCallback:
void OnAuthenticatorMakeCredential(
SecurePaymentConfirmationService::MakePaymentCredentialCallback callback,
@@ -119,6 +131,7 @@
std::unique_ptr<PasskeyBrowserBinder> passkey_browser_binder_;
scoped_refptr<BrowserBoundKeyStore> test_browser_bound_key_store_;
std::string browser_bound_key_store_keychain_access_group_;
+ std::unique_ptr<webauthn::RemoteValidation> remote_validation_;
base::WeakPtrFactory<SecurePaymentConfirmationService> weak_ptr_factory_{
this};
diff --git a/components/payments/content/secure_payment_confirmation_service_unittest.cc b/components/payments/content/secure_payment_confirmation_service_unittest.cc
index 0b72b5f..2e59ed7 100644
--- a/components/payments/content/secure_payment_confirmation_service_unittest.cc
+++ b/components/payments/content/secure_payment_confirmation_service_unittest.cc
@@ -23,8 +23,10 @@
#include "components/webauthn/core/browser/mock_internal_authenticator.h"
#include "content/public/common/content_features.h"
#include "content/public/test/browser_task_environment.h"
+#include "content/public/test/navigation_simulator.h"
#include "content/public/test/test_browser_context.h"
#include "content/public/test/test_web_contents_factory.h"
+#include "mojo/public/cpp/test_support/test_utils.h"
#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/blink/public/common/features.h"
@@ -44,12 +46,6 @@
namespace {
-struct SecurePaymentConfirmationServiceDeleter {
- void operator()(SecurePaymentConfirmationService* spc_service) {
- spc_service->ResetAndDeleteThis();
- }
-};
-
#if !BUILDFLAG(IS_IOS)
static const int32_t kAlgorithmIdentifier = 1;
static const int32_t kAnotherAlgorithmIdentifier = 2;
@@ -58,6 +54,12 @@
#endif
+struct SecurePaymentConfirmationServiceDeleter {
+ void operator()(SecurePaymentConfirmationService* spc_service) {
+ spc_service->ResetAndDeleteThis();
+ }
+};
+
} // namespace
// Base class for unittests testing SecurePaymentConfirmationService, which
@@ -73,15 +75,20 @@
protected:
void InitializeSecurePaymentConfirmationService(
bool with_authenticator = true,
- bool is_off_the_record = false) {
+ bool is_off_the_record = false,
+ std::string caller_url = "https://relying-party.example") {
context_.set_is_off_the_record(is_off_the_record);
web_contents_ = web_contents_factory_.CreateWebContents(&context_);
CHECK(!mock_internal_authenticator_);
CHECK(!spc_service_);
- mojo::PendingRemote<mojom::SecurePaymentConfirmationService> remote;
+ content::NavigationSimulator::NavigateAndCommitFromBrowser(
+ web_contents_, GURL(caller_url));
+
mojo::PendingReceiver<mojom::SecurePaymentConfirmationService> receiver =
- remote.InitWithNewPipeAndPassReceiver();
+ spc_service_remote_.BindNewPipeAndPassReceiver();
+ // Retain the remote at class scope to ensure the service remains alive
+ // during RunLoop execution.
spc_service_ = std::unique_ptr<SecurePaymentConfirmationService,
SecurePaymentConfirmationServiceDeleter>(
new SecurePaymentConfirmationService(
@@ -116,6 +123,9 @@
base::MockCallback<mojom::SecurePaymentConfirmationService::
SecurePaymentConfirmationAvailabilityCallback>
mock_secure_payment_confirmation_availability_callback_;
+ // Retain the remote at class scope to prevent `spc_service_` (which is a
+ // DocumentService) from self-destructing when a RunLoop executes.
+ mojo::Remote<mojom::SecurePaymentConfirmationService> spc_service_remote_;
};
class SecurePaymentConfirmationServiceTest
@@ -297,6 +307,66 @@
/*expected_bucket_count=*/1);
}
+TEST_F(SecurePaymentConfirmationServiceTest,
+ StorePaymentCredential_RpIdCheckFailed) {
+ base::test::ScopedFeatureList features;
+ features.InitWithFeatures({::features::kSecurePaymentConfirmation}, {});
+
+ InitializeSecurePaymentConfirmationService(
+ /*with_authenticator=*/false,
+ /*is_off_the_record=*/false,
+ /*caller_url=*/"https://attacker.example");
+
+ const std::vector<uint8_t> credential_id = {0x01, 0x02, 0x03, 0x04};
+ const std::string rp_id = "relying-party.example";
+ const std::vector<uint8_t> user_id = {0x10, 0x11, 0x12, 0x13};
+ base::MockCallback<
+ mojom::SecurePaymentConfirmationService::StorePaymentCredentialCallback>
+ mock_store_payment_credential_callback;
+
+ mojo::test::BadMessageObserver bad_message_observer;
+
+ spc_service_remote_->StorePaymentCredential(
+ credential_id, rp_id, user_id,
+ mock_store_payment_credential_callback.Get());
+
+ spc_service_.release();
+
+ EXPECT_EQ("Invalid RP ID in StorePaymentCredential",
+ bad_message_observer.WaitForBadMessage());
+}
+
+TEST_F(SecurePaymentConfirmationServiceTest,
+ StorePaymentCredential_RpIdCheckSuccess) {
+ base::test::ScopedFeatureList features;
+ features.InitWithFeatures(
+ {::features::kSecurePaymentConfirmation,
+ features::kSecurePaymentConfirmationUseCredentialStoreAPIs},
+ {});
+
+ InitializeSecurePaymentConfirmationService();
+
+ const std::vector<uint8_t> credential_id = {0x01, 0x02, 0x03, 0x04};
+ const std::string rp_id = "relying-party.example";
+ const std::vector<uint8_t> user_id = {0x10, 0x11, 0x12, 0x13};
+
+ base::MockCallback<
+ mojom::SecurePaymentConfirmationService::StorePaymentCredentialCallback>
+ mock_store_payment_credential_callback;
+
+ base::RunLoop run_loop;
+
+ EXPECT_CALL(mock_store_payment_credential_callback,
+ Run(mojom::PaymentCredentialStorageStatus::SUCCESS))
+ .WillOnce([&run_loop] { run_loop.Quit(); });
+
+ spc_service_remote_->StorePaymentCredential(
+ credential_id, rp_id, user_id,
+ mock_store_payment_credential_callback.Get());
+
+ run_loop.Run();
+}
+
#if !BUILDFLAG(IS_IOS)
struct CredentialTestParams {
Regression Test / PoC
diff --git a/components/payments/content/secure_payment_confirmation_service_unittest.cc b/components/payments/content/secure_payment_confirmation_service_unittest.cc
index 0b72b5f..2e59ed7 100644
--- a/components/payments/content/secure_payment_confirmation_service_unittest.cc
+++ b/components/payments/content/secure_payment_confirmation_service_unittest.cc
@@ -23,8 +23,10 @@
#include "components/webauthn/core/browser/mock_internal_authenticator.h"
#include "content/public/common/content_features.h"
#include "content/public/test/browser_task_environment.h"
+#include "content/public/test/navigation_simulator.h"
#include "content/public/test/test_browser_context.h"
#include "content/public/test/test_web_contents_factory.h"
+#include "mojo/public/cpp/test_support/test_utils.h"
#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/blink/public/common/features.h"
@@ -44,12 +46,6 @@
namespace {
-struct SecurePaymentConfirmationServiceDeleter {
- void operator()(SecurePaymentConfirmationService* spc_service) {
- spc_service->ResetAndDeleteThis();
- }
-};
-
#if !BUILDFLAG(IS_IOS)
static const int32_t kAlgorithmIdentifier = 1;
static const int32_t kAnotherAlgorithmIdentifier = 2;
@@ -58,6 +54,12 @@
#endif
+struct SecurePaymentConfirmationServiceDeleter {
+ void operator()(SecurePaymentConfirmationService* spc_service) {
+ spc_service->ResetAndDeleteThis();
+ }
+};
+
} // namespace
// Base class for unittests testing SecurePaymentConfirmationService, which
@@ -73,15 +75,20 @@
protected:
void InitializeSecurePaymentConfirmationService(
bool with_authenticator = true,
- bool is_off_the_record = false) {
+ bool is_off_the_record = false,
+ std::string caller_url = "https://relying-party.example") {
context_.set_is_off_the_record(is_off_the_record);
web_contents_ = web_contents_factory_.CreateWebContents(&context_);
CHECK(!mock_internal_authenticator_);
CHECK(!spc_service_);
- mojo::PendingRemote<mojom::SecurePaymentConfirmationService> remote;
+ content::NavigationSimulator::NavigateAndCommitFromBrowser(
+ web_contents_, GURL(caller_url));
+
mojo::PendingReceiver<mojom::SecurePaymentConfirmationService> receiver =
- remote.InitWithNewPipeAndPassReceiver();
+ spc_service_remote_.BindNewPipeAndPassReceiver();
+ // Retain the remote at class scope to ensure the service remains alive
+ // during RunLoop execution.
spc_service_ = std::unique_ptr<SecurePaymentConfirmationService,
SecurePaymentConfirmationServiceDeleter>(
new SecurePaymentConfirmationService(
@@ -116,6 +123,9 @@
base::MockCallback<mojom::SecurePaymentConfirmationService::
SecurePaymentConfirmationAvailabilityCallback>
mock_secure_payment_confirmation_availability_callback_;
+ // Retain the remote at class scope to prevent `spc_service_` (which is a
+ // DocumentService) from self-destructing when a RunLoop executes.
+ mojo::Remote<mojom::SecurePaymentConfirmationService> spc_service_remote_;
};
class SecurePaymentConfirmationServiceTest
@@ -297,6 +307,66 @@
/*expected_bucket_count=*/1);
}
+TEST_F(SecurePaymentConfirmationServiceTest,
+ StorePaymentCredential_RpIdCheckFailed) {
+ base::test::ScopedFeatureList features;
+ features.InitWithFeatures({::features::kSecurePaymentConfirmation}, {});
+
+ InitializeSecurePaymentConfirmationService(
+ /*with_authenticator=*/false,
+ /*is_off_the_record=*/false,
+ /*caller_url=*/"https://attacker.example");
+
+ const std::vector<uint8_t> credential_id = {0x01, 0x02, 0x03, 0x04};
+ const std::string rp_id = "relying-party.example";
+ const std::vector<uint8_t> user_id = {0x10, 0x11, 0x12, 0x13};
+ base::MockCallback<
+ mojom::SecurePaymentConfirmationService::StorePaymentCredentialCallback>
+ mock_store_payment_credential_callback;
+
+ mojo::test::BadMessageObserver bad_message_observer;
+
+ spc_service_remote_->StorePaymentCredential(
+ credential_id, rp_id, user_id,
+ mock_store_payment_credential_callback.Get());
+
+ spc_service_.release();
+
+ EXPECT_EQ("Invalid RP ID in StorePaymentCredential",
+ bad_message_observer.WaitForBadMessage());
+}
+
+TEST_F(SecurePaymentConfirmationServiceTest,
+ StorePaymentCredential_RpIdCheckSuccess) {
+ base::test::ScopedFeatureList features;
+ features.InitWithFeatures(
+ {::features::kSecurePaymentConfirmation,
+ features::kSecurePaymentConfirmationUseCredentialStoreAPIs},
+ {});
+
+ InitializeSecurePaymentConfirmationService();
+
+ const std::vector<uint8_t> credential_id = {0x01, 0x02, 0x03, 0x04};
+ const std::string rp_id = "relying-party.example";
+ const std::vector<uint8_t> user_id = {0x10, 0x11, 0x12, 0x13};
+
+ base::MockCallback<
+ mojom::SecurePaymentConfirmationService::StorePaymentCredentialCallback>
+ mock_store_payment_credential_callback;
+
+ base::RunLoop run_loop;
+
+ EXPECT_CALL(mock_store_payment_credential_callback,
+ Run(mojom::PaymentCredentialStorageStatus::SUCCESS))
+ .WillOnce([&run_loop] { run_loop.Quit(); });
+
+ spc_service_remote_->StorePaymentCredential(
+ credential_id, rp_id, user_id,
+ mock_store_payment_credential_callback.Get());
+
+ run_loop.Run();
+}
+
#if !BUILDFLAG(IS_IOS)
struct CredentialTestParams {