CVE-2026-118349
Overview
Background
- `PaymentRequest`
- the browser-side object (
components/payments/content/payment_request.cc) backing the renderer’sPaymentRequestMojo interface; it owns the dialog lifecycle and self-destructs viaResetAndDeleteThis(). - `PaymentRequestDialogView`
- the Views widget showing the payment UI. Its
ShowDialog()performs a window-size check (CheckIfDialogFitsInBrowserWindow()); if the dialog does not fit it rejects the request. - `OnInternalError()`
- called when the request must abort for an internal reason (e.g. window too small); it notified the renderer client and then destroyed the
PaymentRequest. - Document Picture-in-Picture (PiP)
- a web-exposed detached window.
PipChildDialogObserverHelpersynchronously enlarges the PiP window bounds when a child dialog widget is shown. - `OnWidgetBoundsChanged()`
- fires synchronously on bounds changes; for the payment dialog it re-runs
CheckIfDialogFitsInBrowserWindow(). - `weak_ptr_factory_` / `base::WeakPtr`
- used to bind a posted task to
PaymentRequestso the task no-ops if the object is already gone.
Root Cause Analysis
The commit title and body name the bug directly: a use-after-free when the window-size check fails synchronously. When PaymentRequest.show() is invoked inside a Document PiP window, creating and showing PaymentRequestDialogView causes PipChildDialogObserverHelper to synchronously resize the PiP window before ShowDialog() returns. That synchronous resize fires PaymentRequestDialogView::OnWidgetBoundsChanged(), which calls CheckIfDialogFitsInBrowserWindow(); for a PiP window the auto-resize omits the 5% safety margin (a separate bug, crbug.com/573056666), so the check fails. The old code handled a failed check in ShowDialog() by calling request_->OnInternalError(errors::kBrowserWindowTooSmall) synchronously, and OnInternalError() ended with ResetAndDeleteThis().
The fatal consequence: ResetAndDeleteThis() destroys the PaymentRequest object while PaymentRequest::Show() — and the dialog-creation code it called — is still on the call stack. When control unwinds back through ShowDialog()/Show(), it touches the already-freed PaymentRequest (and this dialog view), a use-after-free. The original code had even left a TODO(crbug.com/483395708) noting Show() could not handle this synchronously and had posted the error asynchronously from the dialog view; the regression is that other synchronous paths (the PiP bounds-change path) now reach OnInternalError() directly. The fix makes OnInternalError() itself asynchronous for all callers: it now posts a task bound via weak_ptr_factory_.GetWeakPtr() to a new OnInternalErrorImpl(), which performs the original client_->OnError(...) + ResetAndDeleteThis() work. Because the self-deletion is deferred to a fresh task and bound to a weak pointer, the object is never destroyed while any caller is still on its stack. The dialog view’s own call site is simplified to a direct request_->OnInternalError(...) since OnInternalError is now inherently async.
OnInternalError() synchronously called ResetAndDeleteThis(), destroying the PaymentRequest while Show()/ShowDialog() were still on the stack; a PiP window’s synchronous auto-resize drove a size-check failure into that path, so the object freed itself mid-call — a use-after-free fixed by posting the teardown as a weak-pointer-bound task.Attack Path
- Web attacker
a page opens a Document Picture-in-Picture window and (per the test) massages its committed URL to the opener’s HTTPS origin so Payment Request APIs are allowed in it (inferred from the regression test’s
documentPictureInPicture.requestWindow()+history.replaceStatedance). - Size the PiP window sets the PiP window to just above the pre-widget minimum so that showing the dialog triggers a synchronous auto-enlarge.
- Call `show()`
invokes
PaymentRequest.show()from the PiP frame;ShowDialog()creates the dialog widget. - Synchronous resize + failed check
PipChildDialogObserverHelperenlarges the PiP window synchronously, firingOnWidgetBoundsChanged()→CheckIfDialogFitsInBrowserWindow(), which fails (no 5% margin for PiP). - Synchronous self-delete
OnInternalError()runsResetAndDeleteThis(), freeingPaymentRequestwhileShow()is still unwinding. - Primitive
subsequent use of the freed
PaymentRequest/dialog objects on the unwinding stack is a use-after-free in the browser process (freed-memory access; controlled reuse/escalation is (inferred)).
Impact Assessment
ResetAndDeleteThis() reentered from the dialog’s bounds-change callback during Show(). Strength as a primitive (whether the freed memory can be reclaimed and shaped before the stale access) is not demonstrated by the diff and is (inferred); at minimum it is a browser-process UAF, which is a high-value target because it is outside the renderer sandbox.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcomponents/payments/content/payment_request.cc |
modified |
Files Changed
components/payments/content/payment_request.ccchrome/browser/ui/views/payments/payment_request_window_size_browsertest.ccchrome/browser/ui/views/payments/payment_request_dialog_view.cccomponents/payments/content/payment_request.h
Audit Directions
- Synchronous self-deletiongrep for
ResetAndDeleteThis()/delete thisreachable synchronously from a public entry point (Show,Start, event callbacks); each is a UAF risk if a reentrant callback fires mid-call. - PiP synchronous bounds changesaudit other dialogs/widgets shown inside Document PiP whose
OnWidgetBoundsChanged()runs logic that can destroy the owner, givenPipChildDialogObserverHelperresizes synchronously. - Payment Request in PiPper the in-code
TODO(crbug.com/573056666), review whether Payment Request should be allowed in PiP at all and whetherCheckIfDialogFitsInBrowserWindow()’s missing 5% margin causes other failures. - WeakPtr-posted teardown patternconfirm other abort/error handlers that free browser-side request objects post via
WeakPtrrather than deleting inline.
Patch
From 0609c16f1f026d8c5ec0519671cfc8f943a64bcf Mon Sep 17 00:00:00 2001
From: Stephen McGruer <smcgruer@chromium.org>
Date: Fri, 16 Oct 2026 08:20:12 -0700
Subject: [PATCH] [Payment Handler] Avoid UAF when synchronously failing window size check
When PaymentRequest.show() is called inside a Document
Picture-in-Picture window, creating and showing the
PaymentRequestDialogView child widget causes
PipChildDialogObserverHelper to synchronously enlarge the PiP window
bounds before ShowDialog() returns. That synchronous bounds change fires
PaymentRequestDialogView::OnWidgetBoundsChanged(), which calls
CheckIfDialogFitsInBrowserWindow().
CheckIfDialogFitsInBrowserWindow will then fail for a PiP window (which
is another bug - crbug.com/573056666) as the auto-resize doesn't include
our 5% safety margin. This failing then synchronously invokes
OnInternalError(), which calls ResetAndDeleteThis(). The PaymentRequest
object is thus destroyed while PaymentRequest::Show() is still on the
call stack.
This CL moves the asynchronous PostTask into
PaymentRequest::OnInternalError() so that all callers avoid synchronous
self-deletion. It also removes the obsolete TODO(crbug.com/483395708)
comment, as there is no point in it now that OnInternalError has to be
async for other callers too.
Fixed: 572422506
Test: browser_tests --gtest_filter="PaymentRequestWindowSizeTest.*"
Change-Id: I25afd5bb2bbbdacf42d449002f8510ab2b503b73
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8562498
Commit-Queue: Xuehui Chen <xuehuichen@google.com>
Reviewed-by: Slobodan Pejic <slobodan@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1720717}
---
diff --git a/chrome/browser/ui/views/payments/payment_request_dialog_view.cc b/chrome/browser/ui/views/payments/payment_request_dialog_view.cc
index 78496c8..a7bd77af 100644
--- a/chrome/browser/ui/views/payments/payment_request_dialog_view.cc
+++ b/chrome/browser/ui/views/payments/payment_request_dialog_view.cc
@@ -158,15 +158,7 @@
VLOG(2) << "ShowDialog: Rejected because dialog does not fit";
request_->SetWindowSizeCheckRejectionReason(
JourneyLogger::WindowSizeCheckRejectionReason::kRejectedAtShow);
-
- // To avoid tearing down the PaymentRequest class in the middle of showing
- // the dialog, we post this call asynchronously.
- //
- // TODO(crbug.com/483395708): Refactor PaymentRequest::Show so that it can
- // handle this case synchronously, e.g. via a bool return parameter.
- base::SingleThreadTaskRunner::GetCurrentDefault()->PostTask(
- FROM_HERE, base::BindOnce(&PaymentRequest::OnInternalError, request_,
- errors::kBrowserWindowTooSmall));
+ request_->OnInternalError(errors::kBrowserWindowTooSmall);
// Because we never handed over ownership of this object to the widget in
// ShowWebModalDialogViews, we need to manually delete ourselves.
diff --git a/components/payments/content/payment_request.cc b/components/payments/content/payment_request.cc
index 90e8339..45f07b78 100644
--- a/components/payments/content/payment_request.cc
+++ b/components/payments/content/payment_request.cc
@@ -14,6 +14,7 @@
#include "base/logging.h"
#include "base/metrics/histogram_functions.h"
#include "base/strings/string_util.h"
+#include "base/task/sequenced_task_runner.h"
#include "components/payments/content/content_payment_request_delegate.h"
#include "components/payments/content/has_enrolled_instrument_query_factory.h"
#include "components/payments/content/payment_app.h"
@@ -1004,21 +1005,13 @@
}
void PaymentRequest::OnInternalError(const std::string& error_message) {
- // If |client_| is not bound, then the object is already being destroyed as
- // a result of a renderer event.
- if (!client_.is_bound()) {
- return;
- }
-
- RecordFirstAbortReason(JourneyLogger::ABORT_REASON_INTERNAL_ERROR);
-
- client_->OnError(mojom::PaymentErrorReason::NOT_SUPPORTED, error_message);
-
- if (observer_for_testing_) {
- observer_for_testing_->OnInternalError();
- }
-
- ResetAndDeleteThis();
+ // To avoid tearing down the PaymentRequest class while it or its delegate
+ // may still be on the call stack (e.g., during ShowDialog() or if showing
+ // the dialog triggers a synchronous browser window resize), post the error
+ // handling and self-deletion asynchronously.
+ base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
+ FROM_HERE, base::BindOnce(&PaymentRequest::OnInternalErrorImpl,
+ weak_ptr_factory_.GetWeakPtr(), error_message));
}
void PaymentRequest::SetWindowSizeCheckRejectionReason(
@@ -1281,6 +1274,24 @@
}
}
+void PaymentRequest::OnInternalErrorImpl(const std::string& error_message) {
+ // If |client_| is not bound, then the object is already being destroyed as
+ // a result of a renderer event.
+ if (!client_.is_bound()) {
+ return;
+ }
+
+ RecordFirstAbortReason(JourneyLogger::ABORT_REASON_INTERNAL_ERROR);
+
+ client_->OnError(mojom::PaymentErrorReason::NOT_SUPPORTED, error_message);
+
+ if (observer_for_testing_) {
+ observer_for_testing_->OnInternalError();
+ }
+
+ ResetAndDeleteThis();
+}
+
void PaymentRequest::ShowErrorMessageAndAbortPayment() {
// Note that both branches of the if-else will invoke the OnUserCancelled()
// method.
diff --git a/components/payments/content/payment_request.h b/components/payments/content/payment_request.h
index a7e61e7f..a0e7b91 100644
--- a/components/payments/content/payment_request.h
+++ b/components/payments/content/payment_request.h
@@ -122,7 +122,8 @@
void OnPayerInfoSelected(mojom::PayerDetailPtr payer_info) override;
// Called when the Payment Request is being aborted due to an internal error,
- // such as the browser window being too small to show the UX.
+ // such as the browser window being too small to show the UX. Posts a task to
+ // notify the client and destroy this object asynchronously.
void OnInternalError(const std::string& error_message);
// Called when the user wants to authenticate in a different way. This is
@@ -240,6 +241,10 @@
void OnAbortResult(bool aborted);
+ // Notifies the renderer client of an internal error and destroys `this`.
+ // Posted as a task by `OnInternalError()`.
+ void OnInternalErrorImpl(const std::string& error_message);
+
// Show an error message in the UI (if available) and abort payment.
void ShowErrorMessageAndAbortPayment();
diff --git a/chrome/browser/ui/views/payments/payment_request_window_size_browsertest.cc b/chrome/browser/ui/views/payments/payment_request_window_size_browsertest.cc
index c936798..620ebec 100644
--- a/chrome/browser/ui/views/payments/payment_request_window_size_browsertest.cc
+++ b/chrome/browser/ui/views/payments/payment_request_window_size_browsertest.cc
@@ -5,15 +5,18 @@
#include "base/numerics/safe_conversions.h"
#include "base/test/metrics/histogram_tester.h"
#include "build/build_config.h"
+#include "chrome/browser/picture_in_picture/picture_in_picture_window_manager.h"
#include "chrome/browser/ui/browser_window.h"
#include "chrome/browser/ui/browser_window/public/browser_window_interface.h"
#include "chrome/browser/ui/views/payments/payment_request_browsertest_base.h"
+#include "chrome/browser/ui/views/payments/payment_request_views_util.h"
#include "chrome/test/base/ui_test_utils.h"
#include "components/payments/core/features.h"
#include "components/payments/core/journey_logger.h"
#include "content/public/test/browser_test.h"
#include "content/public/test/browser_test_utils.h"
#include "ui/gfx/geometry/rect.h"
+#include "ui/views/test/widget_test_api.h"
#include "ui/views/view.h"
#include "ui/views/widget/widget.h"
@@ -95,7 +98,9 @@
EXPECT_NE(nullptr, dialog_view());
// Resize the window to be too small.
- ResetEventWaiter(DialogEvent::INTERNAL_ERROR);
+ ResetEventWaiterForSequence(
+ {DialogEvent::DIALOG_SIZE_CHECK_AFTER_BROWSER_RESIZE,
+ DialogEvent::INTERNAL_ERROR});
ui_test_utils::SetAndWaitForBounds(*browser(), gfx::Rect(0, 0, 100, 100));
// We need to wait for the throttle timer (100ms).
@@ -178,4 +183,74 @@
JourneyLogger::WindowSizeCheckRejectionReason::kRejectedAtResize, 0);
}
+// Regression test for crbug.com/572422506 where synchronous Picture-in-Picture
+// window auto-enlarging caused a UAF via CheckIfDialogFitsInBrowserWindow.
+IN_PROC_BROWSER_TEST_F(PaymentRequestWindowSizeTest,
+ RejectInAutoEnlargedPictureInPictureWindow) {
+ std::string method_name;
+ InstallPaymentApp("a.com", "/payment_request_success_responder.js",
+ &method_name);
+ NavigateTo("/payment_request_no_shipping_test.html");
+
+ // A newly created Document Picture-in-Picture window has a committed URL of
+ // about:blank, which UrlUtil::IsOriginAllowedToUseWebPaymentApis() rejects.
+ // Using document.open() followed by history.replaceState() updates the PiP
+ // main frame's committed URL to the opener's HTTPS URL via a same-document
+ // navigation without closing the PiP window.
+ //
+ // TODO(crbug.com/573056666): Either disallow Payment Request in PiP entirely,
+ // or fix it so that this hack is not needed.
+ ASSERT_EQ(true, content::EvalJs(
+ GetActiveWebContents(),
+ "documentPictureInPicture.requestWindow().then(w => { "
+ "w.document.open(); "
+ "w.document.close(); "
+ "w.history.replaceState(null, '', location.href); "
+ "return true; })"));
+
+ content::WebContents* pip_web_contents =
+ PictureInPictureWindowManager::GetInstance()->GetChildWebContents();
+ ASSERT_NE(nullptr, pip_web_contents);
+
+ views::Widget* pip_widget = views::Widget::GetTopLevelWidgetForNativeView(
+ pip_web_contents->GetNativeView());
+ ASSERT_NE(nullptr, pip_widget);
+
+ // Size the PiP window to the minimum pre-widget size required by ShowDialog()
+ // (1.05 * kDialogMinWidth x kDialogHeight, with a 0 top offset). When the
+ // child dialog widget is shown, PipChildDialogObserverHelper synchronously
+ // enlarges the PiP window to fit the unscaled dialog plus title bar padding
+ // (without the 5% safety buffer), which then fails the post-widget size check
+ // in CheckIfDialogFitsInBrowserWindow().
+ const int width = base::ClampRound(kDialogMinWidth * 1.05f);
+ const int height = base::ClampRound(kDialogHeight * 1.05f);
+ views::AsyncWidgetRequestWaiter bounds_waiter(*pip_widget);
+ pip_widget->SetBounds(gfx::Rect(0, 0, width, height));
+ bounds_waiter.Wait();
+
+ base::HistogramTester histograms;
+
+ ResetEventWaiterForSequence(
+ {DialogEvent::PROCESSING_SPINNER_SHOWN,
+ DialogEvent::DIALOG_SIZE_CHECK_AFTER_BROWSER_RESIZE,
+ DialogEvent::INTERNAL_ERROR});
+
+ EXPECT_EQ(
+ "NotSupportedError: Browser window is too small for Payment Request UI.",
+ content::EvalJs(
+ pip_web_contents,
+ content::JsReplace(
+ "const request = new PaymentRequest([{supportedMethods: $1}], "
+ "{total: {label: 'Total', amount: {currency: 'USD', value: "
+ "'1.00'}}}); "
+ "request.show().catch(e => e.toString());",
+ method_name)));
+
+ ASSERT_TRUE(WaitForObservedEvent());
+
+ histograms.ExpectUniqueSample(
+ "PaymentRequest.WindowSizeCheckRejectionReason",
+ JourneyLogger::WindowSizeCheckRejectionReason::kRejectedAtResize, 1);
+}
+
} // namespace payments
Regression Test / PoC
diff --git a/chrome/browser/ui/views/payments/payment_request_window_size_browsertest.cc b/chrome/browser/ui/views/payments/payment_request_window_size_browsertest.cc
index c936798..620ebec 100644
--- a/chrome/browser/ui/views/payments/payment_request_window_size_browsertest.cc
+++ b/chrome/browser/ui/views/payments/payment_request_window_size_browsertest.cc
@@ -5,15 +5,18 @@
#include "base/numerics/safe_conversions.h"
#include "base/test/metrics/histogram_tester.h"
#include "build/build_config.h"
+#include "chrome/browser/picture_in_picture/picture_in_picture_window_manager.h"
#include "chrome/browser/ui/browser_window.h"
#include "chrome/browser/ui/browser_window/public/browser_window_interface.h"
#include "chrome/browser/ui/views/payments/payment_request_browsertest_base.h"
+#include "chrome/browser/ui/views/payments/payment_request_views_util.h"
#include "chrome/test/base/ui_test_utils.h"
#include "components/payments/core/features.h"
#include "components/payments/core/journey_logger.h"
#include "content/public/test/browser_test.h"
#include "content/public/test/browser_test_utils.h"
#include "ui/gfx/geometry/rect.h"
+#include "ui/views/test/widget_test_api.h"
#include "ui/views/view.h"
#include "ui/views/widget/widget.h"
@@ -95,7 +98,9 @@
EXPECT_NE(nullptr, dialog_view());
// Resize the window to be too small.
- ResetEventWaiter(DialogEvent::INTERNAL_ERROR);
+ ResetEventWaiterForSequence(
+ {DialogEvent::DIALOG_SIZE_CHECK_AFTER_BROWSER_RESIZE,
+ DialogEvent::INTERNAL_ERROR});
ui_test_utils::SetAndWaitForBounds(*browser(), gfx::Rect(0, 0, 100, 100));
// We need to wait for the throttle timer (100ms).
@@ -178,4 +183,74 @@
JourneyLogger::WindowSizeCheckRejectionReason::kRejectedAtResize, 0);
}
+// Regression test for crbug.com/572422506 where synchronous Picture-in-Picture
+// window auto-enlarging caused a UAF via CheckIfDialogFitsInBrowserWindow.
+IN_PROC_BROWSER_TEST_F(PaymentRequestWindowSizeTest,
+ RejectInAutoEnlargedPictureInPictureWindow) {
+ std::string method_name;
+ InstallPaymentApp("a.com", "/payment_request_success_responder.js",
+ &method_name);
+ NavigateTo("/payment_request_no_shipping_test.html");
+
+ // A newly created Document Picture-in-Picture window has a committed URL of
+ // about:blank, which UrlUtil::IsOriginAllowedToUseWebPaymentApis() rejects.
+ // Using document.open() followed by history.replaceState() updates the PiP
+ // main frame's committed URL to the opener's HTTPS URL via a same-document
+ // navigation without closing the PiP window.
+ //
+ // TODO(crbug.com/573056666): Either disallow Payment Request in PiP entirely,
+ // or fix it so that this hack is not needed.
+ ASSERT_EQ(true, content::EvalJs(
+ GetActiveWebContents(),
+ "documentPictureInPicture.requestWindow().then(w => { "
+ "w.document.open(); "
+ "w.document.close(); "
+ "w.history.replaceState(null, '', location.href); "
+ "return true; })"));
+
+ content::WebContents* pip_web_contents =
+ PictureInPictureWindowManager::GetInstance()->GetChildWebContents();
+ ASSERT_NE(nullptr, pip_web_contents);
+
+ views::Widget* pip_widget = views::Widget::GetTopLevelWidgetForNativeView(
+ pip_web_contents->GetNativeView());
+ ASSERT_NE(nullptr, pip_widget);
+
+ // Size the PiP window to the minimum pre-widget size required by ShowDialog()
+ // (1.05 * kDialogMinWidth x kDialogHeight, with a 0 top offset). When the
+ // child dialog widget is shown, PipChildDialogObserverHelper synchronously
+ // enlarges the PiP window to fit the unscaled dialog plus title bar padding
+ // (without the 5% safety buffer), which then fails the post-widget size check
+ // in CheckIfDialogFitsInBrowserWindow().
+ const int width = base::ClampRound(kDialogMinWidth * 1.05f);
+ const int height = base::ClampRound(kDialogHeight * 1.05f);
+ views::AsyncWidgetRequestWaiter bounds_waiter(*pip_widget);
+ pip_widget->SetBounds(gfx::Rect(0, 0, width, height));
+ bounds_waiter.Wait();
+
+ base::HistogramTester histograms;
+
+ ResetEventWaiterForSequence(
+ {DialogEvent::PROCESSING_SPINNER_SHOWN,
+ DialogEvent::DIALOG_SIZE_CHECK_AFTER_BROWSER_RESIZE,
+ DialogEvent::INTERNAL_ERROR});
+
+ EXPECT_EQ(
+ "NotSupportedError: Browser window is too small for Payment Request UI.",
+ content::EvalJs(
+ pip_web_contents,
+ content::JsReplace(
+ "const request = new PaymentRequest([{supportedMethods: $1}], "
+ "{total: {label: 'Total', amount: {currency: 'USD', value: "
+ "'1.00'}}}); "
+ "request.show().catch(e => e.toString());",
+ method_name)));
+
+ ASSERT_TRUE(WaitForObservedEvent());
+
+ histograms.ExpectUniqueSample(
+ "PaymentRequest.WindowSizeCheckRejectionReason",
+ JourneyLogger::WindowSizeCheckRejectionReason::kRejectedAtResize, 1);
+}
+
} // namespace payments