CVE-2026-106360
Overview
Files Changed
components/payments/core/native_error_strings.cccomponents/payments/content/installable_payment_app_crawler_unittest.ccchrome/browser/payments/manifest_verifier_browsertest.cccomponents/payments/content/payment_manifest_downloader.cccomponents/payments/content/installable_payment_app_crawler.cccomponents/payments/content/installable_payment_app_crawler.hcomponents/payments/content/payment_manifest_downloader_unittest.ccchrome/android/javatests/src/org/chromium/chrome/browser/payments/PaymentManifestDownloaderTest.javacomponents/payments/core/native_error_strings.hcomponents/payments/content/BUILD.gnchrome/browser/payments/service_worker_payment_app_finder_browsertest.cc
Patch
From 10863f9a4609d7ff7fdb27a5ecd1c4b58e8438ad Mon Sep 17 00:00:00 2001 From: Xuehui Chen <[email protected]> Date: Fri, 04 Sep 2026 09:04:34 -0700 Subject: [PATCH] [Payments] Sanitize manifest download and crawler errors Previously, PaymentManifestDownloader and InstallablePaymentAppCrawler surfaced detailed error messages (including cross-origin redirect destinations, HTTP status codes, network errors, and service worker URLs/scopes) directly to the calling page via PaymentRequest.show() rejections. This allowed untrusted pages to probe network responses and leak sensitive cross-origin URLs. This CL addresses this information leak by separating developer console logging from client-facing errors: * DevTools vs. Client Errors: Detailed diagnostic error strings are logged to the developer console for debugging, while generic, non- leaking error messages are returned to the web page. * PaymentManifestDownloader: Downloader callbacks now receive generic download failure messages referencing only the initial request URL. Detailed messages containing redirect locations, HTTP response codes, and net errors are logged only to DevTools. * InstallablePaymentAppCrawler: Although the bug report focuses on the downloader, the crawler similarly processes manifests and service worker configurations. To prevent cross-origin URL and scope leakage when validating web app manifests or service worker fields, the crawler is updated to log detailed validation errors to the console and return a generic installation failure message. * clean up: Remove redundant out_error_message reference passed to IsValidManifestUrl and ParseRedirectUrl. Bug: 535639435 Change-Id: Iabccfdffd56a65ef52c801cd0d23cc7d0e00ac3c Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8280660 Reviewed-by: Stephen McGruer <[email protected]> Commit-Queue: Xuehui Chen <[email protected]> Cr-Commit-Position: refs/heads/main@{#1692498} --- diff --git a/chrome/android/javatests/src/org/chromium/chrome/browser/payments/PaymentManifestDownloaderTest.java b/chrome/android/javatests/src/org/chromium/chrome/browser/payments/PaymentManifestDownloaderTest.java index fb022bbf..19dee5b6 100644 --- a/chrome/android/javatests/src/org/chromium/chrome/browser/payments/PaymentManifestDownloaderTest.java +++ b/chrome/android/javatests/src/org/chromium/chrome/browser/payments/PaymentManifestDownloaderTest.java @@ -156,10 +156,7 @@ Assert.assertTrue("Web app manifest should not have been downloaded.", mDownloadFailure); Assert.assertEquals( - "Unable to download payment manifest \"" - + url.getSpec() - + "\". HTTP 404 Not Found.", - mErrorMessage); + "Unable to download payment manifest \"" + url.getSpec() + "\".", mErrorMessage); } @Test @@ -188,10 +185,7 @@ Assert.assertTrue( "Payment method manifest should have not have been downloaded.", mDownloadFailure); Assert.assertEquals( - "Unable to download payment manifest \"" - + url.getSpec() - + "\". HTTP 404 Not Found.", - mErrorMessage); + "Unable to download payment manifest \"" + url.getSpec() + "\".", mErrorMessage); } @Test diff --git a/components/payments/content/installable_payment_app_crawler.cc b/components/payments/content/installable_payment_app_crawler.cc index 5758e72..b52fd934 100644 --- a/components/payments/content/installable_payment_app_crawler.cc +++ b/components/payments/content/installable_payment_app_crawler.cc @@ -133,7 +133,7 @@ number_of_payment_method_manifest_to_download_--; if (content.empty()) { - SetFirstError(error_message); + ReportManifestDownloaderError(error_message); FinishCrawlingPaymentAppsIfReady(); return; } @@ -176,12 +176,12 @@ if (!IsSameOriginWith(method_manifest_url_after_redirects, web_app_manifest_url)) { number_of_web_app_manifest_to_download_--; - std::string cross_origin_error_message = base::ReplaceStringPlaceholders( - errors::kCrossOriginWebAppManifestNotAllowed, - {web_app_manifest_url.spec(), - method_manifest_url_after_redirects.spec()}, - nullptr); - SetFirstError(cross_origin_error_message); + ReportAppInstallError(base::ReplaceStringPlaceholders( + errors::kCrossOriginWebAppManifestNotAllowed, + {web_app_manifest_url.spec(), + method_manifest_url_after_redirects.spec()}, + nullptr), + method_manifest_url); continue; } @@ -207,7 +207,7 @@ CHECK(!content.empty()); OnPaymentWebAppManifestDownloaded( method_manifest_url, web_app_manifest_url, web_app_manifest_url, - content, /*error_message=*/""); + content, /*unused_error_message=*/""); continue; } @@ -228,7 +228,7 @@ const GURL& web_app_manifest_url, const GURL& web_app_manifest_url_after_redirects, const std::string& content, - const std::string& error_message) { + const std::string& unused_error_message) { #if DCHECK_IS_ON() GURL::Replacements replacements; if (ignore_port_in_origin_comparison_for_testing_) @@ -242,7 +242,11 @@ number_of_web_app_manifest_to_download_--; if (content.empty()) { - SetFirstError(error_message); + // Do not forward `unused_error_message` because it contains the web app + // manifest URL, which should not be exposed to the merchant website. Only + // `method_manifest_url` should be exposed. DevTools logging was already + // handled by PaymentManifestDownloader. + SetInstallErrorForMethod(method_manifest_url); FinishCrawlingPaymentAppsIfReady(); return; } @@ -259,9 +263,10 @@ crawling_mode_ == CrawlingMode::kJustInTimeInstallation && !base::FeatureList::IsEnabled( features::kAllowJITInstallationWhenAppIconIsMissing)) { - std::string icon_error_message = base::ReplaceStringPlaceholders( - errors::kInvalidWebAppIcon, {web_app_manifest_url.spec()}, nullptr); - SetFirstError(icon_error_message); + ReportAppInstallError(base::ReplaceStringPlaceholders( + errors::kInvalidWebAppIcon, + {web_app_manifest_url.spec()}, nullptr), + method_manifest_url); // App without a valid icon is not JIT installable. installable_apps_.erase(method_manifest_url); } @@ -278,7 +283,8 @@ return false; if (app_info->sw_js_url.empty() || !base::IsStringUTF8(app_info->sw_js_url)) { - SetFirstError(errors::kInvalidServiceWorkerUrl); + ReportAppInstallError(errors::kInvalidServiceWorkerUrl, + method_manifest_url); return false; } @@ -286,23 +292,28 @@ if (!GURL(app_info->sw_js_url).is_valid()) { GURL absolute_url = web_app_manifest_url.Resolve(app_info->sw_js_url); if (!absolute_url.is_valid()) { - SetFirstError(base::ReplaceStringPlaceholders( - errors::kCannotResolveServiceWorkerUrl, - {app_info->sw_js_url, web_app_manifest_url.spec()}, nullptr)); + ReportAppInstallError( + base::ReplaceStringPlaceholders( + errors::kCannotResolveServiceWorkerUrl, + {app_info->sw_js_url, web_app_manifest_url.spec()}, nullptr), + method_manifest_url); return false; } app_info->sw_js_url = absolute_url.spec(); } if (!IsSameOriginWith(web_app_manifest_url, GURL(app_info->sw_js_url))) { - SetFirstError(base::ReplaceStringPlaceholders( - errors::kCrossOriginServiceWorkerUrlNotAllowed, - {app_info->sw_js_url, web_app_manifest_url.spec()}, nullptr)); + ReportAppInstallError( + base::ReplaceStringPlaceholders( + errors::kCrossOriginServiceWorkerUrlNotAllowed, + {app_info->sw_js_url, web_app_manifest_url.spec()}, nullptr), + method_manifest_url); return false; } if (!app_info->sw_scope.empty() && !base::IsStringUTF8(app_info->sw_scope)) { - SetFirstError(errors::kInvalidServiceWorkerScope); + ReportAppInstallError(errors::kInvalidServiceWorkerScope, + method_manifest_url); return false; } @@ -310,18 +321,22 @@ GURL absolute_scope = web_app_manifest_url.GetWithoutFilename().Resolve(app_info->sw_scope); if (!absolute_scope.is_valid()) { - SetFirstError(base::ReplaceStringPlaceholders( - errors::kCannotResolveServiceWorkerScope, - {app_info->sw_scope, web_app_manifest_url.spec()}, nullptr)); + ReportAppInstallError( + base::ReplaceStringPlaceholders( + errors::kCannotResolveServiceWorkerScope, + {app_info->sw_scope, web_app_manifest_url.spec()}, nullptr), + method_manifest_url); return false; } app_info->sw_scope = absolute_scope.spec(); } if (!IsSameOriginWith(web_app_manifest_url, GURL(app_info->sw_scope))) { - SetFirstError(base::ReplaceStringPlaceholders( - errors::kCrossOriginServiceWorkerScopeNotAllowed, - {app_info->sw_scope, web_app_manifest_url.spec()}, nullptr)); + ReportAppInstallError( + base::ReplaceStringPlaceholders( + errors::kCrossOriginServiceWorkerScopeNotAllowed, + {app_info->sw_scope, web_app_manifest_url.spec()}, nullptr), + method_manifest_url); return false; } @@ -329,14 +344,15 @@ if (!content::PaymentAppProviderUtil::IsValidInstallablePaymentApp( web_app_manifest_url, GURL(app_info->sw_js_url), GURL(app_info->sw_scope), &error_message)) { - SetFirstError(error_message); + ReportAppInstallError(error_message, method_manifest_url); return false; } // TODO(crbug.com/40548519): Support multiple installable payment apps for a // payment method. if (installable_apps_.find(method_manifest_url) != installable_apps_.end()) { - SetFirstError(errors::kInstallingMultipleDefaultAppsNotSupported); + ReportAppInstallError(errors::kInstallingMultipleDefaultAppsNotSupported, + method_manifest_url); return false; } @@ -495,13 +511,10 @@ if (icon.drawsNothing() && !base::FeatureList::IsEnabled( features::kAllowJITInstallationWhenAppIconIsMissing)) { - log_.Error(base::StrCat( - {"Failed to download or decode the icon from web app manifest \"", - web_app_manifest_url.spec(), "\" for payment handler manifest \"", - method_manifest_url.spec(), "\"."})); - std::string error_message = base::ReplaceStringPlaceholders( - errors::kInvalidWebAppIcon, {web_app_manifest_url.spec()}, nullptr); - SetFirstError(error_message); + ReportAppInstallError(base::ReplaceStringPlaceholders( + errors::kInvalidWebAppIcon, + {web_app_manifest_url.spec()}, nullptr), + method_manifest_url); installable_apps_.erase(it); } else { it->second->icon = std::make_unique<SkBitmap>(icon); @@ -553,11 +566,27 @@ std::move(finished_using_resources_).Run(); } -void InstallablePaymentAppCrawler::SetFirstError( - const std::string& error_message) { - log_.Error(error_message); - if (first_error_message_.empty()) - first_error_message_ = error_message; +void InstallablePaymentAppCrawler::SetInstallErrorForMethod( + const GURL& method_manifest_url) { + if (first_error_message_.empty()) { + first_error_message_ = base::ReplaceStringPlaceholders( + errors::kPaymentHandlerInstallFailedForMethod, + {method_manifest_url.spec()}, nullptr); + } +} + +void InstallablePaymentAppCrawler::ReportAppInstallError( + const std::string& devtools_error_message, + const GURL& method_manifest_url) { + log_.Error(devtools_error_message); + SetInstallErrorForMethod(method_manifest_url); +} + +void InstallablePaymentAppCrawler::ReportManifestDownloaderError( + const std::string& client_error_message) { + if (first_error_message_.empty()) { + first_error_message_ = client_error_message; + } } } // namespace payments. diff --git a/components/payments/content/installable_payment_app_crawler.h b/components/payments/content/installable_payment_app_crawler.h index 44cd6f42..39caf1b 100644 --- a/components/payments/content/installable_payment_app_crawler.h +++ b/components/payments/content/installable_payment_app_crawler.h @@ -107,7 +107,7 @@ const GURL& web_app_manifest_url, const GURL& web_app_manifest_url_after_redirects, const std::string& content, - const std::string& error_message); + const std::string& unused_error_message); bool CompleteAndStorePaymentWebAppInfoIfValid( const GURL& method_manifest_url, const GURL& web_app_manifest_url, @@ -123,7 +123,19 @@ const SkBitmap& icon); void PostTaskToFinishCrawlingPaymentAppsIfReady(); void FinishCrawlingPaymentAppsIfReady(); - void SetFirstError(const std::string& error_message); + // Sets a generic client-facing error message constructed for + // |method_manifest_url| if |first_error_message_| is not already set. Does + // not log to the DevTools console. + void SetInstallErrorForMethod(const GURL& method_manifest_url); + // Logs a rich |devtools_error_message| to the developer console and sets a + // generic client-facing error message constructed for |method_manifest_url| + // if |first_error_message_| is not already set. + void ReportAppInstallError(const std::string& devtools_error_message, + const GURL& method_manifest_url); + // Sets |client_error_message| if |first_error_message_| is not already set. + // Does not log to the DevTools console as PaymentManifestDownloader has + // already done that. + void ReportManifestDownloaderError(const std::string& client_error_message); DeveloperConsoleLogger log_; const url::Origin merchant_origin_; diff --git a/components/payments/content/payment_manifest_downloader.cc b/components/payments/content/payment_manifest_downloader.cc index da6446f2..37df860 100644 --- a/components/payments/content/payment_manifest_downloader.cc +++ b/components/payments/content/payment_manifest_downloader.cc @@ -52,82 +52,109 @@ network::SimpleURLLoader::kMaxBoundedStringDownloadSize, "Max manifest size bigger than largest allowed download size"); -void RespondWithHttpStatusCodeError(const GURL& final_url, - int response_code, - const ErrorLogger& log, - PaymentManifestDownloadCallback callback) { - std::string error_message = - GenerateHttpStatusCodeError(final_url, response_code); - log.Error(error_message); - std::move(callback).Run(final_url, std::string(), error_message); +// Returns a generic client-facing error message for manifest download failures +// using |url_before_redirects|. +std::string CreateManifestDownloadFailureMessage( + const GURL& url_before_redirects) { + return base::ReplaceStringPlaceholders(errors::kPaymentManifestDownloadFailed, + {url_before_redirects.spec()}, + nullptr); } -void RespondWithNetworkError(const GURL& final_url, - int net_error, - const ErrorLogger& log, - PaymentManifestDownloadCallback callback) { - std::string error_message = GenerateNetworkErrorMessage(final_url, net_error); - log.Error(error_message); - std::move(callback).Run(final_url, std::string(), error_message); -} - -// Invokes |callback| with |error_format|. -void RespondWithError(std::string_view error_format, +// Invokes |callback| with a generic failure message using +// |url_before_redirects| and logs |devtools_error_message| to |log|. +void RespondWithError(const std::string& devtools_error_message, + const GURL& url_before_redirects, const GURL& final_url, const ErrorLogger& log, PaymentManifestDownloadCallback callback) { - std::string error_message = base::ReplaceStringPlaceholders( - error_format, {final_url.spec()}, nullptr); - log.Error(error_message); - std::move(callback).Run(final_url, std::string(), error_message); + log.Error(devtools_error_message); + std::move(callback).Run( + final_url, /*contents=*/std::string(), + CreateManifestDownloadFailureMessage(url_before_redirects)); } -// Invokes the |callback| with |response_body|. If |response_body| is empty, -// then invokes |callback| with |empty_error_format|. +// Invokes |callback| with a generic failure message using +// |url_before_redirects| and logs |devtools_error_format| formatted with +// |final_url| to |log|. +void RespondWithTemplateError(std::string_view devtools_error_format, + const GURL& url_before_redirects, + const GURL& final_url, + const ErrorLogger& log, + PaymentManifestDownloadCallback callback) { + RespondWithError(base::ReplaceStringPlaceholders(devtools_error_format, + {final_url.spec()}, nullptr), + url_before_redirects, final_url, log, std::move(callback)); +} + +// Invokes |callback| with a generic failure message using +// |url_before_redirects| and logs an HTTP status code error with |final_url| to +// |log|. +void RespondWithHttpStatusCodeError(const GURL& url_before_redirects, + const GURL& final_url, + int response_code, + const ErrorLogger& log, + PaymentManifestDownloadCallback callback) { + RespondWithError(GenerateHttpStatusCodeError(final_url, response_code), ... (file diff truncated)
Regression Test / PoC
diff --git a/chrome/browser/payments/manifest_verifier_browsertest.cc b/chrome/browser/payments/manifest_verifier_browsertest.cc
index d7dcb247..a40a3f6a 100644
--- a/chrome/browser/payments/manifest_verifier_browsertest.cc
+++ b/chrome/browser/payments/manifest_verifier_browsertest.cc
@@ -450,7 +450,7 @@
SinglePaymentMethodName404) {
std::string expected_pattern =
"Unable to download payment manifest "
- "\"https://127.0.0.1:\\d+/404.test/webpay\". HTTP 404 Not Found.";
+ "\"https://127.0.0.1:\\d+/404.test/webpay\".";
{
content::InstalledPaymentAppsFinder::PaymentApps apps;
apps[0] = std::make_unique<content::StoredPaymentApp>();
@@ -489,8 +489,7 @@
MultiplePaymentMethodName404) {
std::string expected_pattern =
"Unable to download payment manifest "
- "\"https://127.0.0.1:\\d+/404(aswell)?.test/webpay\". HTTP 404 Not "
- "Found.";
+ "\"https://127.0.0.1:\\d+/404(aswell)?.test/webpay\".";
{
content::InstalledPaymentAppsFinder::PaymentApps apps;
apps[0] = std::make_unique<content::StoredPaymentApp>();
diff --git a/chrome/browser/payments/service_worker_payment_app_finder_browsertest.cc b/chrome/browser/payments/service_worker_payment_app_finder_browsertest.cc
index aec834b..a5036929 100644
--- a/chrome/browser/payments/service_worker_payment_app_finder_browsertest.cc
+++ b/chrome/browser/payments/service_worker_payment_app_finder_browsertest.cc
@@ -659,8 +659,8 @@
IN_PROC_BROWSER_TEST_F(ServiceWorkerPaymentAppFinderBrowserTest,
InvalidDifferentSiteRedirect) {
std::string expected_pattern =
- "Cross-site redirect from \"https://larrypay.test:\\d+/webpay\" to "
- "\"https://kylepay.test/webpay\" not allowed for payment manifests.";
+ "Unable to download payment manifest "
+ "\"https://larrypay.test:\\d+/webpay\".";
{
GetAllPaymentAppsForMethods({"https://larrypay.test/webpay"});
@@ -688,15 +688,17 @@
// it redirects 4 times (charlie -> david -> frank -> george -> harry).
IN_PROC_BROWSER_TEST_F(ServiceWorkerPaymentAppFinderBrowserTest,
FourRedirectsIsNotValid) {
- std::string expected_error_message =
- "Unable to download the payment manifest because reached the maximum "
- "number of redirects.";
+ std::string expected_pattern =
+ "Unable to download payment manifest "
+ "\"https://charlie.example.test:\\d+/webpay\".";
{
GetAllPaymentAppsForMethods({"https://charlie.example.test/webpay"});
EXPECT_TRUE(apps().empty());
EXPECT_TRUE(installable_apps().empty());
- EXPECT_EQ(expected_error_message, error_message());
+ EXPECT_TRUE(RE2::FullMatch(error_message(), expected_pattern))
+ << "Actual error message \"" << error_message()
+ << "\" did not match expected pattern \"" << expected_pattern << "\".";
}
// Repeat lookups should have identical results.
@@ -705,7 +707,9 @@
EXPECT_TRUE(apps().empty());
EXPECT_TRUE(installable_apps().empty());
- EXPECT_EQ(expected_error_message, error_message());
+ EXPECT_TRUE(RE2::FullMatch(error_message(), expected_pattern))
+ << "Actual error message \"" << error_message()
+ << "\" did not match expected pattern \"" << expected_pattern << "\".";
}
}
@@ -763,10 +767,8 @@
IN_PROC_BROWSER_TEST_F(ServiceWorkerPaymentAppFinderBrowserTest,
CrossOriginHttpLinkHeaderIsInvalid) {
std::string expected_pattern =
- "Cross-origin payment method manifest "
- "\"https://harry.example.test/payment-manifest.json\" not allowed for "
- "the "
- "payment method \"https://ike.example.test:\\d+/webpay\".";
+ "Unable to download payment manifest "
+ "\"https://ike.example.test:\\d+/webpay\".";
{
GetAllPaymentAppsForMethods({"https://ike.example.test/webpay"});
@@ -793,19 +795,15 @@
// its cross-origin default application https://harry.example.test/app.json.
IN_PROC_BROWSER_TEST_F(ServiceWorkerPaymentAppFinderBrowserTest,
CrossOriginDefaultApplicationIsInvalid) {
- std::string expected_pattern =
- "Cross-origin default application https://harry.example.test/app.json "
- "not "
- "allowed in payment method manifest "
- "https://john.example.test:\\d+/payment-manifest.json.";
+ std::string expected_error_message =
+ "Failed to install the payment handler for "
+ "\"https://john.example.test/webpay\".";
{
GetAllPaymentAppsForMethods({"https://john.example.test/webpay"});
EXPECT_TRUE(apps().empty());
EXPECT_TRUE(installable_apps().empty());
- EXPECT_TRUE(RE2::FullMatch(error_message(), expected_pattern))
- << "Actual error message \"" << error_message()
- << "\" did not match expected pattern \"" << expected_pattern << "\".";
+ EXPECT_EQ(expected_error_message, error_message());
}
// Repeat lookups should have identical results.
@@ -814,9 +812,7 @@
EXPECT_TRUE(apps().empty());
EXPECT_TRUE(installable_apps().empty());
- EXPECT_TRUE(RE2::FullMatch(error_message(), expected_pattern))
- << "Actual error message \"" << error_message()
- << "\" did not match expected pattern \"" << expected_pattern << "\".";
+ EXPECT_EQ(expected_error_message, error_message());
}
}
@@ -825,9 +821,8 @@
IN_PROC_BROWSER_TEST_F(ServiceWorkerPaymentAppFinderBrowserTest,
CrossOriginServiceWorkerIsInvalid) {
std::string expected_error_message =
- "Cross-origin \"serviceworker\".\"src\" "
- "https://harry.example.test/app.js "
- "not allowed in web app manifest https://kyle.example.test/app.json.";
+ "Failed to install the payment handler for "
+ "\"https://kyle.example.test/webpay\".";
{
GetAllPaymentAppsForMethods({"https://kyle.example.test/webpay"});
@@ -869,9 +864,8 @@
IN_PROC_BROWSER_TEST_F(ServiceWorkerPaymentAppFinderBrowserTest,
CrossOriginServiceWorkerScopeIsInvalid) {
std::string expected_error_message =
- "Cross-origin \"serviceworker\".\"scope\" "
- "https://harry.example.test/webpay not allowed in web app manifest "
- "https://larry.example.test/app.json.";
+ "Failed to install the payment handler for "
+ "\"https://larry.example.test/webpay\".";
{
GetAllPaymentAppsForMethods({"https://larry.example.test/webpay"});
diff --git a/components/payments/content/installable_payment_app_crawler_unittest.cc b/components/payments/content/installable_payment_app_crawler_unittest.cc
new file mode 100644
index 0000000..f77fc80
--- /dev/null
+++ b/components/payments/content/installable_payment_app_crawler_unittest.cc
@@ -0,0 +1,441 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "components/payments/content/installable_payment_app_crawler.h"
+
+#include <map>
+#include <memory>
+#include <optional>
+#include <string>
+#include <string_view>
+#include <utility>
+#include <vector>
+
+#include "base/functional/bind.h"
+#include "base/functional/callback_helpers.h"
+#include "base/run_loop.h"
+#include "base/strings/stringprintf.h"
+#include "base/test/run_until.h"
+#include "components/payments/content/developer_console_logger.h"
+#include "components/payments/content/payment_manifest_downloader.h"
+#include "components/payments/content/utility/payment_manifest_parser.h"
+#include "components/payments/core/const_csp_checker.h"
+#include "content/public/browser/permission_result.h"
+#include "content/public/browser/weak_document_ptr.h"
+#include "content/public/test/mock_permission_manager.h"
+#include "content/public/test/test_browser_context.h"
+#include "content/public/test/test_renderer_host.h"
+#include "content/public/test/web_contents_tester.h"
+#include "net/base/net_errors.h"
+#include "net/http/http_response_headers.h"
+#include "net/http/http_status_code.h"
+#include "services/network/public/cpp/shared_url_loader_factory.h"
+#include "services/network/public/cpp/weak_wrapper_shared_url_loader_factory.h"
+#include "services/network/public/mojom/url_response_head.mojom.h"
+#include "services/network/test/test_url_loader_factory.h"
+#include "testing/gmock/include/gmock/gmock.h"
+#include "testing/gtest/include/gtest/gtest.h"
+#include "third_party/blink/public/mojom/payments/payment_request.mojom.h"
+#include "third_party/skia/include/core/SkBitmap.h"
+#include "url/gurl.h"
+#include "url/origin.h"
+
+namespace payments {
+namespace {
+
+using testing::_;
+using testing::ElementsAre;
+using testing::NiceMock;
+
+constexpr char kPaymentMethodManifestUrl[] =
+ "https://bobpay.test/payment-manifest.json";
+constexpr char kWebAppManifestUrl[] = "https://bobpay.test/app.json";
+constexpr char kPaymentMethodManifestLinkHeader[] =
+ "<https://bobpay.test/payment-manifest.json>; "
+ "rel=\"payment-method-manifest\"";
+constexpr char kPaymentMethodManifestResponseBody[] = R"({
+ "default_applications": ["https://bobpay.test/app.json"]
+})";
+
+std::string CreateWebAppManifestResponseBody(std::string_view sw_src,
+ std::string_view sw_scope) {
+ return base::StringPrintf(R"({
+ "name": "Bob Pay",
+ "icons": [{
+ "src": "icon.png",
+ "sizes": "48x48",
+ "type": "image/png"
+ }],
+ "serviceworker": {
+ "src": "%s",
+ "scope": "%s"
+ }
+ })",
+ std::string(sw_src).c_str(),
+ std::string(sw_scope).c_str());
+}
+
+class InstallablePaymentAppCrawlerTest
+ : public content::RenderViewHostTestHarness {
+ public:
+ InstallablePaymentAppCrawlerTest()
+ : test_manifest_url_("https://bobpay.test/method.json"),
+ merchant_origin_(url::Origin::Create(GURL("https://merchant.test"))),
+ shared_url_loader_factory_(
+ base::MakeRefCounted<network::WeakWrapperSharedURLLoaderFactory>(
+ &test_factory_)),
+ const_csp_checker_(std::make_unique<ConstCSPChecker>(/*allow=*/true)) {}
+
+ void SetUp() override {
+ content::RenderViewHostTestHarness::SetUp();
+
+ // Grant PAYMENT_HANDLER permission by default so the crawler can proceed
+ // with downloading and validating web app manifests.
+ auto mock_permission_manager =
+ std::make_unique<NiceMock<content::MockPermissionManager>>();
+ ON_CALL(*mock_permission_manager,
+ GetPermissionResultForOriginWithoutContext(_, _, _))
+ .WillByDefault(testing::Return(content::PermissionResult(
+ blink::mojom::PermissionStatus::GRANTED,
+ content::PermissionStatusSource::UNSPECIFIED)));
+ static_cast<content::TestBrowserContext*>(browser_context())
+ ->SetPermissionControllerDelegate(std::move(mock_permission_manager));
+
+ mojo::Remote<network::mojom::URLLoaderFactory> url_loader_factory_rfh;
+ test_factory_.Clone(url_loader_factory_rfh.BindNewPipeAndPassReceiver());
+
+ downloader_ = std::make_unique<PaymentManifestDownloader>(
+ std::make_unique<DeveloperConsoleLogger>(web_contents()),
+ const_csp_checker_->GetWeakPtr(), shared_url_loader_factory_,
+ std::move(url_loader_factory_rfh), main_rfh()->GetWeakDocumentPtr());
+
+ parser_ = std::make_unique<PaymentManifestParser>(
+ std::make_unique<DeveloperConsoleLogger>(web_contents()));
+
+ crawler_ = std::make_unique<InstallablePaymentAppCrawler>(
+ merchant_origin_, main_rfh(), downloader_.get(), parser_.get(),
+ /*cache=*/nullptr);
+ }
+
+ const std::vector<std::string>& logged_errors() {
+ return content::RenderFrameHostTester::For(main_rfh())
+ ->GetConsoleMessages();
+ }
+
+ void SetPaymentMethodManifestResponse(
+ int response_code,
+ const std::optional<std::string>& link_header,
+ const std::string& response_body,
+ int net_error = net::OK) {
+ auto head = network::mojom::URLResponseHead::New();
+ head->headers = base::MakeRefCounted<net::HttpResponseHeaders>("");
+ head->headers->ReplaceStatusLine(
+ base::StringPrintf("HTTP/1.1 %d %s", response_code,
+ net::GetHttpReasonPhrase(response_code)));
+ if (link_header.has_value()) {
+ head->headers->SetHeader("Link", *link_header);
+ }
+ test_factory_.AddResponse(test_manifest_url_, std::move(head),
+ response_body,
+ network::URLLoaderCompletionStatus(net_error));
+ }
+
+ void SetResponseBodyResponse(const GURL& url,
+ int response_code,
+ const std::string& response_body,
+ int net_error = net::OK) {
+ auto head = network::mojom::URLResponseHead::New();
+ head->headers = base::MakeRefCounted<net::HttpResponseHeaders>("");
+ head->headers->ReplaceStatusLine(
+ base::StringPrintf("HTTP/1.1 %d %s", response_code,
+ net::GetHttpReasonPhrase(response_code)));
... (truncated)
Original Bug Report
`PaymentManifestDownloader` error strings embed cross-origin redirect `Location` URL / HTTP status / net-error and are surfaced to page JS via `PaymentRequest.show()` rejection `.message`
Security Bug
VULNERABILITY DETAILS
PaymentManifestDownloader fetches a page-supplied cross-origin URL (the
supportedMethods payment-method identifier) from the browser process
and formats failures into error strings that embed cross-origin response
data:
components/payments/content/payment_manifest_downloader.cc:220-224kPaymentManifestCrossSiteRedirectNotAllowed– formats{original_url.spec(), redirect_url.spec()}– the target’sLocationheader URL, including query string.:52-60RespondWithHttpStatusCodeError/GenerateHttpStatusCodeError– formats{final_url, response_code}– the target’s HTTP status code + reason phrase.:265-270kPaymentManifestDownloadFailedWithNetworkError– formats{final_url.spec(), net::ErrorToShortString(net_error), error_code}.:292-296–Link:headerrel="payment-method-manifest"URL.
The error string flows via
components/payments/content/payment_request.cc:698-702
client_->OnError(PaymentErrorReason::NOT_SUPPORTED, ... + error_message)
→ renderer PaymentRequest::OnError
(third_party/blink/renderer/modules/payments/payment_request.cc:1740-1745)
→ the .show() promise rejects with a DOMException whose .message
is that string.
So new PaymentRequest([{supportedMethods:'https://victim/...'}], ...).show() .catch(e => e.message) gives an attacker page the victim’s cross-site
redirect Location URL, or its HTTP status, or its net-error – data
that Fetch’s opaque-response filtering exists to hide.
VERSION
Chrome Version: 149.0.7827.155
Channel: Stable
Operating System: Linux x86_64, kernel 6.12.13-amd64
REPRODUCTION CASE
Attachments: poc_server.py, cert.pem, key.pem.
Steps:
python3 poc_server.pychrome --user-data-dir=/tmp/pay --ignore-certificate-errors https://127.0.0.1:8443/(--ignore-certificate-errorsis self-signed-localhost plumbing only.)- Click probe. (
PaymentRequest.show()requires a user gesture.) - Observe the page’s
<pre>output and thepoc_server.pyterminal.
OBSERVED (bug present):
Attacker page’s .show() rejection .message values:
redirect-xsite: 'The payment method "https://localhost:8444/redirect-xsite"
is not supported. Cross-site redirect from
"https://localhost:8444/redirect-xsite" to
"https://third-party.test/leaked-path?token=SECRET-TOKEN-12345"
not allowed for payment manifests.'
status-403: 'The payment method "https://localhost:8444/status-403" is
not supported. Unable to download payment manifest
"https://localhost:8444/status-403". HTTP 403 Forbidden.'
SECRET-TOKEN-12345 (from the victim’s Location: header) and
403 Forbidden (the victim’s status line) are cross-origin response
data the attacker page reads directly.
ROOT CAUSE
| Site | |
|---|---|
payment_manifest_downloader.cc:220-224 |
kPaymentManifestCrossSiteRedirectNotAllowed embeds redirect_url.spec() |
:52-60 |
GenerateHttpStatusCodeError embeds response_code + reason |
:265-270 |
kPaymentManifestDownloadFailedWithNetworkError embeds net_error |
:292-296 |
Link-header URL embedded |
payment_request.cc:698-702 |
Appends error_message to NOT_SUPPORTED mojo OnError |
blink/.../payment_request.cc:1740-1745 |
Rejects .show() with DOMException(message) |
SUGGESTED FIX
At payment_request.cc:698, do not append the downloader’s
error_message to the client-facing OnError string; keep the DevTools
log_->Error() for developer diagnostics. Or: sanitize the downloader’s
error strings to omit cross-origin response data (redirect URL, status,
net-error, Link URL) – leave only the requested supportedMethods URL
(which the page already knows).
CREDIT INFORMATION
Reporter credit: NH DEV