CVE-2026-17747
Overview
Files Changed
components/payments/content/android/java/src/org/chromium/components/payments/PaymentRequestService.javacomponents/payments/content/android/junit/src/org/chromium/components/payments/PaymentRequestServiceTest.java
Patch
From 347b1a2e80de5da35f0ab0bd991014515691d261 Mon Sep 17 00:00:00 2001 From: Stephen McGruer <[email protected]> Date: Mon, 08 Jun 2026 06:33:42 -0700 Subject: [PATCH] [Payments] Verify URL origin in openPaymentHandlerWindow Before this change, PaymentRequestService.openPaymentHandlerWindow blindly attached the requested GURL to the currently showing payment request flow in Java without verifying its origin. This could allow a compromised renderer to hijack the bottom sheet UI of a concurrent, legitimate payment flow if the C++ origin checks are bypassed (e.g., via a stalled service worker event for the compromised renderer). This CL adds an origin verification check in the Java layer before opening a payment handler window, ensuring that the requested GURL's origin matches the scope/origin of the invoked payment app. Bug: b:500472958 Test: components_junit_tests --gtest_filter="*PaymentRequestServiceTest*" Change-Id: I3c86d2cc25eed46a78d31af7f9487ba77adbd0f3 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7904676 Commit-Queue: Stephen McGruer <[email protected]> Reviewed-by: Darwin Yang <[email protected]> Cr-Commit-Position: refs/heads/main@{#1643149} --- 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 a190112..11fde37 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 @@ -674,8 +674,20 @@ } PaymentApp invokedPaymentApp = showingPaymentRequest.mInvokedPaymentApp; - assert invokedPaymentApp != null; - assert invokedPaymentApp.getPaymentAppType() == PaymentAppType.SERVICE_WORKER_APP; + if (invokedPaymentApp == null + || invokedPaymentApp.getPaymentAppType() != PaymentAppType.SERVICE_WORKER_APP) { + return null; + } + + // Ensure that this request is for the same origin as the invoked payment app, + // to prevent a compromised renderer for one payment app from hijacking the UI of + // a different payment app. + Origin appOrigin = Origin.create(new GURL(invokedPaymentApp.getIdentifier())); + Origin windowOrigin = Origin.create(url); + if (!appOrigin.equals(windowOrigin)) { + return null; + } + assumeNonNull(showingPaymentRequest.mBrowserPaymentRequest); return showingPaymentRequest.mBrowserPaymentRequest.openPaymentHandlerWindow( url, invokedPaymentApp.getUkmSourceId()); diff --git a/components/payments/content/android/junit/src/org/chromium/components/payments/PaymentRequestServiceTest.java b/components/payments/content/android/junit/src/org/chromium/components/payments/PaymentRequestServiceTest.java index d8997a4..f925de8 100644 --- a/components/payments/content/android/junit/src/org/chromium/components/payments/PaymentRequestServiceTest.java +++ b/components/payments/content/android/junit/src/org/chromium/components/payments/PaymentRequestServiceTest.java @@ -40,6 +40,7 @@ import org.chromium.payments.mojom.PaymentOptions; import org.chromium.payments.mojom.PaymentRequestClient; import org.chromium.payments.mojom.PaymentResponse; +import org.chromium.url.GURL; import org.chromium.url.mojom.Url; import java.util.ArrayList; @@ -958,4 +959,81 @@ "Insecure"); assertErrorAndReason("Insecure", PaymentErrorReason.NOT_ALLOWED_ERROR); } + + @Test + @Feature({"Payments"}) + public void testOpenPaymentHandlerWindow_noPaymentFlow() { + GURL targetUrl = new GURL("https://alice.example/pay"); + Assert.assertNull(PaymentRequestService.openPaymentHandlerWindow(targetUrl)); + } + + @Test + @Feature({"Payments"}) + public void testOpenPaymentHandlerWindow_noAppInvoked() { + GURL targetUrl = new GURL("https://alice.example/pay"); + PaymentRequestService service = defaultBuilder().build(); + show(service); + Assert.assertNull(PaymentRequestService.openPaymentHandlerWindow(targetUrl)); + } + + @Test + @Feature({"Payments"}) + public void testOpenPaymentHandlerWindow_nativeAppInvoked() { + GURL targetUrl = new GURL("https://alice.example/pay"); + PaymentRequestService service = defaultBuilder().build(); + show(service); + + AndroidPaymentApp nativeApp = Mockito.mock(AndroidPaymentApp.class); + Mockito.doReturn(PaymentAppType.NATIVE_MOBILE_APP).when(nativeApp).getPaymentAppType(); + Mockito.doReturn("alice.example.app").when(nativeApp).packageName(); + service.invokePaymentApp(nativeApp, Mockito.mock(PaymentResponseHelperInterface.class)); + + // A request to open the payment handler window should be denied because the invoked app + // is not a service worker app. + Assert.assertNull(PaymentRequestService.openPaymentHandlerWindow(targetUrl)); + } + + @Test + @Feature({"Payments"}) + public void testOpenPaymentHandlerWindow_sameOrigin() { + GURL targetUrl = new GURL("https://alice.example/pay"); + PaymentRequestService service = defaultBuilder().build(); + show(service); + + PaymentApp swAppSameOrigin = Mockito.mock(PaymentApp.class); + Mockito.doReturn(PaymentAppType.SERVICE_WORKER_APP) + .when(swAppSameOrigin) + .getPaymentAppType(); + Mockito.doReturn("https://alice.example/scope").when(swAppSameOrigin).getIdentifier(); + service.invokePaymentApp( + swAppSameOrigin, Mockito.mock(PaymentResponseHelperInterface.class)); + + WebContents mockWebContents = Mockito.mock(WebContents.class); + Mockito.doReturn(mockWebContents) + .when(mBrowserPaymentRequest) + .openPaymentHandlerWindow(Mockito.any(), Mockito.anyLong()); + + Assert.assertEquals( + mockWebContents, PaymentRequestService.openPaymentHandlerWindow(targetUrl)); + } + + @Test + @Feature({"Payments"}) + public void testOpenPaymentHandlerWindow_crossOrigin() { + GURL targetUrl = new GURL("https://alice.example/pay"); + PaymentRequestService service = defaultBuilder().build(); + show(service); + + PaymentApp swAppCrossOrigin = Mockito.mock(PaymentApp.class); + Mockito.doReturn(PaymentAppType.SERVICE_WORKER_APP) + .when(swAppCrossOrigin) + .getPaymentAppType(); + Mockito.doReturn("https://bob.example/scope").when(swAppCrossOrigin).getIdentifier(); + service.invokePaymentApp( + swAppCrossOrigin, Mockito.mock(PaymentResponseHelperInterface.class)); + + // A request to open the payment handler window should be denied because the invoked app + // has a different origin scope than the target URL. + Assert.assertNull(PaymentRequestService.openPaymentHandlerWindow(targetUrl)); + } }
Original Bug Report
Potential Android Payment Handler BottomSheet hijack via confused-deputy
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: A compromised renderer hosting any service worker can potentially hijack an active Payment Handler BottomSheet on Android to display an attacker-controlled page. This occurs because the fallback mechanism for opening payment handler windows on Android loses the caller’s identity and blindly attaches the requested URL to a process-wide static singleton representing the active payment flow.
Affected files:
components/payments/content/android/java/src/org/chromium/components/payments/PaymentRequestService.javacontent/browser/service_worker/service_worker_version.ccchrome/android/java/src/org/chromium/chrome/browser/ServiceTabLauncher.javachrome/android/java/src/org/chromium/chrome/browser/payments/handler/PaymentHandlerCoordinator.java
Estimated timestamp from git blame: 2025-04-22
Description
On Android, a security vulnerability exists where a compromised renderer can inject an attacker-controlled page into the Payment Handler BottomSheet for an unrelated, in-progress payment flow. This is a confused-deputy issue resulting from a lack of caller authorization checks and the loss of context during a C++ to Java JNI transition.
Root Cause
The vulnerability stems from a sequence of gaps in validation and routing:
-
Missing Authorization Check in C++: When a renderer sends the
blink::mojom::ServiceWorkerHost::OpenPaymentHandlerWindowMojo message,ServiceWorkerVersion::OpenPaymentHandlerWindow(service_worker_version.cc) only validates that the requested URL is same-origin with the calling Service Worker. Crucially, it does not verify if the calling Service Worker is actually authorized to interact with the current payment flow (e.g., by checking if it has a pendingPaymentRequestEvent). -
Android Fallback and Identity Loss:
PaymentHandlerSupport::ShowPaymentHandlerWindowattempts to callChromeContentBrowserClient::ShowPaymentHandlerWindow. On Android, this method explicitly returnsfalse. This triggers a fallback mechanism inShowPaymentHandlerWindowReplier::~ShowPaymentHandlerWindowReplier, which routes the request toServiceWorkerVersion::OpenWindowwith aNEW_POPUPdisposition. This request is eventually routed to JNI viaServiceTabLauncher::LaunchTab. During this transition, the identity of the calling Service Worker is lost. -
Blind Dispatch in Java: In Java,
ServiceTabLauncher.launchTabreceives theNEW_POPUPrequest and blindly calls the static methodPaymentRequestService.openPaymentHandlerWindow(url). -
Process-Global Singleton Hijack:
PaymentRequestService.openPaymentHandlerWindow(url)retrieves the currently active payment flow from a process-wide static singleton (BrowserGlobalPaymentFlowManager.sShowingPaymentRequest). It then attaches the attacker-providedurlto this active flow without verifying that the URL’s origin matches the origin of the legitimately invoked payment app (mInvokedPaymentApp).
Potential Attack Scenario
Note: These are suggested steps; our tooling agent does not yet have the ability to run code.
- An attacker compromises a renderer process (e.g., via a v8 exploit) and controls a Service Worker at
attacker.example. - A victim initiates a legitimate
PaymentRequestonmerchant.exampleand selects a legitimate payment app (e.g.,bank.example). - The browser begins the invocation process, setting
mInvokedPaymentApptobank.exampleand storing the active flow inBrowserGlobalPaymentFlowManager.sShowingPaymentRequest. - The compromised renderer sends the
OpenPaymentHandlerWindowMojo IPC with the URLhttps://attacker.example/phish. - The browser process validates the URL is same-origin with the attacker’s Service Worker and routes the request through the Android fallback mechanism.
ServiceTabLaunchercallsPaymentRequestService.openPaymentHandlerWindow, which attaches the attacker’s phishing URL to the victim’s active payment flow.- The Payment Handler BottomSheet opens, displaying the attacker’s phishing page instead of the intended
bank.exampleinterface.
Because sShowingPaymentRequest is process-wide, this could potentially allow an attacker in a regular profile to hijack a payment flow in an Incognito profile if they occur concurrently.
(Note: Exploitation requires a timing constraint to ensure the Mojo IPC arrives after mInvokedPaymentApp is set, otherwise a Java NullPointerException occurs due to an assert in PaymentRequestService.java:648.)
Suggested Fix
- C++ Validation: In
ServiceWorkerVersion::OpenPaymentHandlerWindow(or at thePaymentRequestEventdispatch level), verify that the calling Service Worker actually has a pendingPaymentRequestEventand is the currently authorized handler for an active payment request before proceeding. - Java Validation: In
PaymentRequestService.openPaymentHandlerWindow, add a strict check to ensure that the origin of the providedurlmatches the origin (or scope) ofmInvokedPaymentAppbefore opening the window.
Evaluated with Chrome root at commit: 137d451a126685dd5010e6609db9f6d4a78d8234
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.