Medium chrome Info Disclosure 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInformation leak in Payments
DescriptionInformation leak in Payments
ComponentPayments
Sub-componentPayments
Bug ClassInfo Disclosure
Tracker535639435
Fix commit10863f9a4609 (chromium/src) +987/-311
CISA KEVNot listed
CreditedNH DEV
Disclosed2026-10-06

Files Changed

  • components/payments/core/native_error_strings.cc
  • components/payments/content/installable_payment_app_crawler_unittest.cc
  • chrome/browser/payments/manifest_verifier_browsertest.cc
  • components/payments/content/payment_manifest_downloader.cc
  • components/payments/content/installable_payment_app_crawler.cc
  • components/payments/content/installable_payment_app_crawler.h
  • components/payments/content/payment_manifest_downloader_unittest.cc
  • chrome/android/javatests/src/org/chromium/chrome/browser/payments/PaymentManifestDownloaderTest.java
  • components/payments/core/native_error_strings.h
  • components/payments/content/BUILD.gn
  • chrome/browser/payments/service_worker_payment_app_finder_browsertest.cc
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)
Loading diff…

Regression Test / PoC

shipped with the fix
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)
Loading diff…

Original Bug Report

reported by [email protected]

`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-224 kPaymentManifestCrossSiteRedirectNotAllowed – formats {original_url.spec(), redirect_url.spec()} – the target’s Location header URL, including query string.
  • :52-60 RespondWithHttpStatusCodeError / GenerateHttpStatusCodeError – formats {final_url, response_code} – the target’s HTTP status code + reason phrase.
  • :265-270 kPaymentManifestDownloadFailedWithNetworkError – formats {final_url.spec(), net::ErrorToShortString(net_error), error_code}.
  • :292-296 – Link: header rel="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 =&gt; 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:

  1. python3 poc_server.py
  2. chrome --user-data-dir=/tmp/pay --ignore-certificate-errors https://127.0.0.1:8443/ (--ignore-certificate-errors is self-signed-localhost plumbing only.)
  3. Click probe. (PaymentRequest.show() requires a user gesture.)
  4. Observe the page’s <pre> output and the poc_server.py terminal.

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

View on issue tracker