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 <xuehuichen@google.com>
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 <smcgruer@chromium.org>
Commit-Queue: Xuehui Chen <xuehuichen@google.com>
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