Medium chrome Memory Corruption 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMemory corruption in WebAppInstalls
DescriptionMemory corruption in WebAppInstalls
ComponentWebAppInstalls
Sub-componentPWA
Bug ClassMemory Corruption
Tracker497299147
Fix commita45a7df89995 (chromium/src) +424/-29
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
for
chrome/browser/web_applications/jobs/manifest_to_web_app_install_info_job.cc
modified
for
chrome/browser/web_applications/web_app_origin_association_task.cc
modified
GURL
components/webapps/services/web_app_origin_association/web_app_origin_association_fetcher.h
modified
WebAppOriginAssociationFetcher
components/webapps/services/web_app_origin_association/web_app_origin_association_fetcher.h
modified
for
content/browser/manifest/manifest_manager_host.cc
modified
TEST_F
chrome/browser/web_applications/jobs/manifest_to_web_app_install_info_job_unittest.cc
modified

Files Changed

  • components/webapps/services/web_app_origin_association/web_app_origin_association_fetcher.cc
  • content/browser/manifest/manifest_browsertest.cc
  • chrome/browser/web_applications/jobs/manifest_to_web_app_install_info_job.cc
  • components/webapps/services/web_app_origin_association/test/test_web_app_origin_association_fetcher.h
  • chrome/browser/web_applications/jobs/manifest_to_web_app_install_info_job_unittest.cc
  • components/webapps/services/web_app_origin_association/web_app_origin_association_fetcher_unittest.cc
  • components/webapps/services/web_app_origin_association/test/test_web_app_origin_association_fetcher.cc
  • content/browser/manifest/manifest_manager_host.cc
  • chrome/browser/web_applications/web_app_origin_association_manager_browsertest.cc
  • components/webapps/services/web_app_origin_association/web_app_origin_association_fetcher.h
  • chrome/browser/web_applications/web_app_origin_association_task.cc
From a45a7df899953eef5d45a6a5510c2039ace64640 Mon Sep 17 00:00:00 2001
From: Dan Murphy <dmurph@chromium.org>
Date: Tue, 15 Sep 2026 11:31:36 -0700
Subject: [PATCH] [PWA] Enforce LNA and block redirects for origin associations

PWA scope_extensions and migration_sources allow web applications to
declare associations with other origins. Previously, fetching
web-app-origin-association files allowed following HTTP redirects and
did not restrict target address spaces based on the initiating app's
address space. This allowed malicious public web apps to perform blind
SSRF and local network port scanning.

This CL resolves these vulnerabilities by:
1. Blocking redirect follows (network::mojom::RedirectMode::kError)
   during origin association fetches.
2. Enforcing Local Network Access (LNA) checks via ClientSecurityState
   with LocalNetworkAccessRequestPolicy::kBlock, preventing public web
   apps from fetching origin association files from private or loopback
   networks.
3. Pre-checking IP literals and address spaces in
   ShouldFetchAssociationFile to immediately reject SSRF attempts while
   preserving enterprise intranet and localhost compatibility when
   initiated by apps in the same or less-public address space.
4. Validating browser-side Mojo IPC inputs from renderers in
   ManifestManagerHost (reporting Mojo bad messages for non-HTTPS or
   opaque scope_extensions) and providing defense-in-depth sanitization
   in ManifestToWebAppInstallInfoJob.
5. Preserving manifest declaration order during origin association task
   execution to prevent timing side-channels. This is a highly
   theoretical and exaggerated attack surface, but easy to maintain
   ordering.

Bug: 514022513
Fixed: 497299147, 557040541
Test: content_browsertests --gtest_filter="ManifestBrowserTest.BadMessage_ParseManifestFromString_ScopeExtensions*"
Test: components_unittests --gtest_filter="WebAppOriginAssociationFetcherTest.*"
Test: unit_tests --gtest_filter="ManifestToWebAppInstallInfoJobTest.*"
Test: browser_tests --gtest_filter="WebAppOriginAssociationManagerTest.*"
Change-Id: I95a37ee1aa849c9a512543958737ffcfde481d4d
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8397081
Reviewed-by: Marijn Kruisselbrink <mek@chromium.org>
Commit-Queue: Daniel Murphy <dmurph@chromium.org>
Reviewed-by: Kunjan Patel <ksukh@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1698258}
---
diff --git a/chrome/browser/web_applications/jobs/manifest_to_web_app_install_info_job.cc b/chrome/browser/web_applications/jobs/manifest_to_web_app_install_info_job.cc
index 625325d..80e57ac 100644
--- a/chrome/browser/web_applications/jobs/manifest_to_web_app_install_info_job.cc
+++ b/chrome/browser/web_applications/jobs/manifest_to_web_app_install_info_job.cc
@@ -443,6 +443,14 @@
   ScopeExtensions apps_scope_extensions;
   for (const auto& scope_extension : scope_extensions) {
     CHECK(scope_extension);
+    // Disallow opaque origins and non-HTTPS origins as defense-in-depth against
+    // corrupted or invalid scope extension entries. IPC validation and bad
+    // message reporting are performed at the IPC boundary in
+    // ManifestManagerHost.
+    if (scope_extension->origin.opaque() ||
+        scope_extension->origin.scheme() != url::kHttpsScheme) {
+      continue;
+    }
     auto new_scope_extension = ScopeExtensionInfo::CreateForOrigin(
         scope_extension->origin, scope_extension->has_origin_wildcard);
     apps_scope_extensions.insert(std::move(new_scope_extension));
@@ -454,7 +462,8 @@
     const blink::mojom::ManifestMigrateFrom& migrate_from) {
   std::optional<webapps::ManifestId> manifest_id =
       webapps::ManifestId::Create(migrate_from.id);
-  if (!manifest_id.has_value()) {
+  if (!manifest_id.has_value() ||
+      url::Origin::Create(manifest_id->value()).opaque()) {
     return std::nullopt;
   }
   return MigrationSource(*manifest_id, migrate_from.behavior,
diff --git a/chrome/browser/web_applications/web_app_origin_association_task.cc b/chrome/browser/web_applications/web_app_origin_association_task.cc
index d3f2385..9bf60862 100644
--- a/chrome/browser/web_applications/web_app_origin_association_task.cc
+++ b/chrome/browser/web_applications/web_app_origin_association_task.cc
@@ -16,7 +16,9 @@
 #include "components/webapps/services/web_app_origin_association/web_app_origin_association_fetcher.h"
 #include "components/webapps/services/web_app_origin_association/web_app_origin_association_parser.h"
 #include "content/public/browser/storage_partition.h"
+#include "services/network/public/cpp/ip_address_space_util.h"
 #include "services/network/public/cpp/shared_url_loader_factory.h"
+#include "services/network/public/mojom/ip_address_space.mojom.h"
 #include "url/gurl.h"
 #include "url/origin.h"
 
@@ -33,10 +35,11 @@
           std::move(origin_associations.migration_sources)),
       owner_(manager),
       callback_(std::move(callback)) {
-  std::set<url::Origin> unique_origins;
+  std::set<url::Origin> seen;
   for (const ScopeExtensionInfo& scope_extension : scope_extensions_input_) {
-    if (!scope_extension.origin.opaque()) {
-      unique_origins.insert(scope_extension.origin);
+    if (!scope_extension.origin.opaque() &&
+        seen.insert(scope_extension.origin).second) {
+      pending_origins_.push_back(scope_extension.origin);
     }
   }
   for (const MigrationSource& migration_source : migration_sources_input_) {
@@ -48,9 +51,10 @@
     if (origin.IsSameOriginWith(web_app_identity_)) {
       continue;
     }
-    unique_origins.insert(origin);
+    if (seen.insert(origin).second) {
+      pending_origins_.push_back(origin);
+    }
   }
-  pending_origins_.assign(unique_origins.begin(), unique_origins.end());
 }
 
 WebAppOriginAssociationManager::Task::~Task() {
@@ -68,8 +72,11 @@
 
 void WebAppOriginAssociationManager::Task::FetchAssociationFile(
     const url::Origin& origin) {
+  network::mojom::IPAddressSpace initiator_address_space =
+      network::GetAddressSpaceFromUrl(web_app_identity_)
+          .value_or(network::mojom::IPAddressSpace::kUnknown);
   owner_->GetFetcher().FetchWebAppOriginAssociationFile(
-      origin,
+      origin, initiator_address_space,
       base::BindOnce(
           &WebAppOriginAssociationManager::Task::OnAssociationFileFetched,
           weak_ptr_factory_.GetWeakPtr(), origin));
diff --git a/components/webapps/services/web_app_origin_association/web_app_origin_association_fetcher.cc b/components/webapps/services/web_app_origin_association/web_app_origin_association_fetcher.cc
index d109f927..06a03f7 100644
--- a/components/webapps/services/web_app_origin_association/web_app_origin_association_fetcher.cc
+++ b/components/webapps/services/web_app_origin_association/web_app_origin_association_fetcher.cc
@@ -11,10 +11,15 @@
 #include "base/functional/bind.h"
 #include "base/time/time.h"
 #include "components/webapps/services/web_app_origin_association/web_app_origin_association_uma_util.h"
+#include "net/base/ip_address.h"
 #include "net/base/registry_controlled_domains/registry_controlled_domain.h"
+#include "net/base/url_util.h"
 #include "net/traffic_annotation/network_traffic_annotation.h"
+#include "services/network/public/cpp/ip_address_space_util.h"
 #include "services/network/public/cpp/resource_request.h"
 #include "services/network/public/cpp/shared_url_loader_factory.h"
+#include "services/network/public/mojom/client_security_state.mojom.h"
+#include "services/network/public/mojom/ip_address_space.mojom.h"
 #include "url/gurl.h"
 
 namespace {
@@ -57,10 +62,27 @@
 constexpr char association_file_name[] =
     ".well-known/web-app-origin-association";
 
-std::unique_ptr<network::SimpleURLLoader> CreateRequester(const GURL& url) {
+std::unique_ptr<network::SimpleURLLoader> CreateRequester(
+    const GURL& url,
+    network::mojom::IPAddressSpace initiator_address_space) {
   auto resource_request = std::make_unique<network::ResourceRequest>();
   resource_request->url = url;
   resource_request->method = "GET";
+  // Block following redirects to prevent SSRF and origin takeover.
+  resource_request->redirect_mode = network::mojom::RedirectMode::kError;
+
+  // Configure ClientSecurityState so that the network service's
+  // LocalNetworkAccessChecker blocks access from more public to more private
+  // address spaces.
+  auto client_security_state = network::mojom::ClientSecurityState::New();
+  client_security_state->ip_address_space = initiator_address_space;
+  client_security_state->is_web_secure_context = true;
+  client_security_state->local_network_access_request_policy =
+      network::mojom::LocalNetworkAccessRequestPolicy::kBlock;
+  resource_request->trusted_params = network::ResourceRequest::TrustedParams();
+  resource_request->trusted_params->client_security_state =
+      std::move(client_security_state);
+
   auto url_loader = network::SimpleURLLoader::Create(
       std::move(resource_request),
       web_app_origin_association_traffic_annotation);
@@ -72,13 +94,41 @@
 }
 
 // Fetching association file from a TLD or an otherwise invalid domain is not
-// allowed.
-bool ShouldFetchAssociationFile(const GURL& resource_url) {
-  if (!resource_url.is_valid() || resource_url.is_empty())
+// allowed. Pre-check IP literals and address spaces to reject SSRF and port
+// scanning upfront.
+bool ShouldFetchAssociationFile(
+    const GURL& resource_url,
+    network::mojom::IPAddressSpace initiator_address_space) {
+  if (!resource_url.is_valid() || resource_url.is_empty()) {
     return false;
+  }
 
-  if (resource_url.HostIsIPAddress())
-    return true;
+  // Association files must be fetched over HTTPS.
+  if (!resource_url.SchemeIs(url::kHttpsScheme)) {
+    return false;
+  }
+
+  if (resource_url.HostIsIPAddress()) {
+    net::IPAddress address;
+    if (!address.AssignFromIPLiteral(resource_url.HostNoBracketsPiece())) {
+      return false;
+    }
+
+    // Link-local (including 169.254.0.0/16 cloud metadata), multicast, and
+    // zero addresses must never be accessed.
+    if (address.IsLinkLocal() || address.IsMulticast() || address.IsZero()) {
+      return false;
+    }
+  }
+
+  // Check if the target's address space is known from the URL (IP literals,
+  // localhost, or .local domains).
+  std::optional<network::mojom::IPAddressSpace> target_space =
+      network::GetAddressSpaceFromUrl(resource_url);
+  if (target_space.has_value()) {
+    return !network::IsLessPublicAddressSpaceLNA(*target_space,
+                                                 initiator_address_space);
+  }
 
   const std::optional<size_t> registry_length =
       net::registry_controlled_domains::GetRegistry(
@@ -118,9 +168,10 @@
 
 void WebAppOriginAssociationFetcher::FetchWebAppOriginAssociationFile(
     const url::Origin& origin,
+    network::mojom::IPAddressSpace initiator_address_space,
     FetchFileCallback callback) {
   const GURL resource_url = origin.GetURL().Resolve(association_file_name);
-  if (!ShouldFetchAssociationFile(resource_url)) {
+  if (!ShouldFetchAssociationFile(resource_url, initiator_address_space)) {
     // Do not proceed if |resource_url| is not valid.
     webapps::WebAppOriginAssociationMetrics::RecordFetchResult(
         webapps::WebAppOriginAssociationMetrics::FetchResult::
@@ -129,13 +180,21 @@
     return;
   }
 
-  SendRequest(resource_url, std::move(callback));
+  SendRequest(resource_url, initiator_address_space, std::move(callback));
+}
+
+void WebAppOriginAssociationFetcher::FetchWebAppOriginAssociationFile(
+    const url::Origin& origin,
+    FetchFileCallback callback) {
+  FetchWebAppOriginAssociationFile(
+      origin, network::mojom::IPAddressSpace::kUnknown, std::move(callback));
 }
 
 void WebAppOriginAssociationFetcher::SendRequest(
     const GURL& url,
+    network::mojom::IPAddressSpace initiator_address_space,
     FetchFileCallback callback) {
-  url_loader_ = CreateRequester(url);
+  url_loader_ = CreateRequester(url, initiator_address_space);
   url_loader_->DownloadToString(
       shared_url_loader_factory_.get(),
       base::BindOnce(&WebAppOriginAssociationFetcher::OnResponse,
diff --git a/components/webapps/services/web_app_origin_association/web_app_origin_association_fetcher.h b/components/webapps/services/web_app_origin_association/web_app_origin_association_fetcher.h
index 772ef0b6..fbb0f1b 100644
--- a/components/webapps/services/web_app_origin_association/web_app_origin_association_fetcher.h
+++ b/components/webapps/services/web_app_origin_association/web_app_origin_association_fetcher.h
@@ -13,6 +13,7 @@
 #include "base/memory/scoped_refptr.h"
 #include "base/memory/weak_ptr.h"
 #include "services/network/public/cpp/simple_url_loader.h"
+#include "services/network/public/mojom/ip_address_space.mojom-forward.h"
 #include "url/origin.h"
 
 class GURL;
@@ -27,6 +28,9 @@
     base::OnceCallback<void(std::optional<std::string> file_content)>;
 
 // Makes network requests to fetch web app origin association files.
+// Enforces Local Network Access (LNA) checks based on initiator_address_space,
+// preventing public web app installation from fetching origin association files
+// from private or loopback networks, and disallows following HTTP redirects.
 class WebAppOriginAssociationFetcher {
  public:
   explicit WebAppOriginAssociationFetcher(
@@ -37,6 +41,15 @@
   WebAppOriginAssociationFetcher& operator=(
       const WebAppOriginAssociationFetcher&) = delete;
 
+  // Fetches the association file for |origin|, specifying the IP address space
+  // of the initiating web app.
+  virtual void FetchWebAppOriginAssociationFile(
+      const url::Origin& origin,
+      network::mojom::IPAddressSpace initiator_address_space,
+      FetchFileCallback callback);
+
+  // Overload that determines initiator address space from the origin or
+  // defaults to kUnknown.
   virtual void FetchWebAppOriginAssociationFile(const url::Origin& origin,
                                                 FetchFileCallback callback);
 
@@ -44,7 +57,9 @@
                               network::SimpleURLLoader::RetryMode retry_mode);
 
  private:
-  void SendRequest(const GURL& url, FetchFileCallback callback);
+  void SendRequest(const GURL& url,
+                   network::mojom::IPAddressSpace initiator_address_space,
+                   FetchFileCallback callback);
   void OnResponse(FetchFileCallback callback,
                   std::optional<std::string> response_body);
 
diff --git a/content/browser/manifest/manifest_manager_host.cc b/content/browser/manifest/manifest_manager_host.cc
index 08919958..46b43ff 100644
--- a/content/browser/manifest/manifest_manager_host.cc
+++ b/content/browser/manifest/manifest_manager_host.cc
@@ -31,6 +31,7 @@
 #include "third_party/icu/source/common/unicode/utf16.h"
 #include "url/gurl.h"
 #include "url/origin.h"
+#include "url/url_constants.h"
 
 namespace content {
 
@@ -198,6 +199,15 @@
       }
     }
 
+    for (const auto& scope_extension : manifest.scope_extensions) {
+      if (scope_extension->origin.opaque()) {
+        return "Manifest scope_extensions origin must not be opaque.";
+      }
+      if (scope_extension->origin.scheme() != url::kHttpsScheme) {
+        return "Manifest scope_extensions origin must use the https scheme.";
+      }
+    }
+
     for (const auto& icon : manifest.icons) {
       if (!icon.src.is_valid()) {
         return "Manifest icon urls must be valid.";
diff --git a/chrome/browser/web_applications/jobs/manifest_to_web_app_install_info_job_unittest.cc b/chrome/browser/web_applications/jobs/manifest_to_web_app_install_info_job_unittest.cc
index f12c714ec..2ab33a2 100644
--- a/chrome/browser/web_applications/jobs/manifest_to_web_app_install_info_job_unittest.cc
+++ b/chrome/browser/web_applications/jobs/manifest_to_web_app_install_info_job_unittest.cc
@@ -712,6 +712,38 @@
             web_app_info->migration_sources[0].manifest_id().spec());
 }
 
+TEST_F(ManifestToWebAppInstallInfoJobTest,
+       ScopeExtensionsDropsNonHttpsAndOpaqueData) {
+  SetupBasicPageState();
+  auto& manifest = GetPageManifest();
+
+  {
+    auto scope_ext_http = blink::mojom::ManifestScopeExtension::New();
+    scope_ext_http->origin =
+        url::Origin::Create(GURL("http://insecure.example.com/"));
+    scope_ext_http->has_origin_wildcard = false;
+    manifest->scope_extensions.push_back(std::move(scope_ext_http));
+  }
+  {
+    auto scope_ext_opaque = blink::mojom::ManifestScopeExtension::New();
+    scope_ext_opaque->origin = url::Origin();
+    scope_ext_opaque->has_origin_wildcard = false;
+    manifest->scope_extensions.push_back(std::move(scope_ext_opaque));
+  }
+  {
+    auto scope_ext_valid = blink::mojom::ManifestScopeExtension::New();
+    scope_ext_valid->origin =
+        url::Origin::Create(GURL("https://secure.example.com/"));
+    scope_ext_valid->has_origin_wildcard = false;
+    manifest->scope_extensions.push_back(std::move(scope_ext_valid));
+  }
+
+  auto web_app_info = GetWebAppInstallInfoFromJob(*manifest);
+  ASSERT_EQ(1u, web_app_info->scope_extensions.size());
+  EXPECT_EQ(url::Origin::Create(GURL("https://secure.example.com/")),
+            web_app_info->scope_extensions.begin()->origin);
+}
+
 TEST_F(ManifestToWebAppInstallInfoJobTest, InvalidManifestUrl) {
   SetupBasicPageState();
   auto& manifest = GetPageManifest();
diff --git a/chrome/browser/web_applications/web_app_origin_association_manager_browsertest.cc b/chrome/browser/web_applications/web_app_origin_association_manager_browsertest.cc
index 703ca95..eb42c66 100644
--- a/chrome/browser/web_applications/web_app_origin_association_manager_browsertest.cc
+++ b/chrome/browser/web_applications/web_app_origin_association_manager_browsertest.cc
@@ -290,10 +290,12 @@
               std::make_unique<net::test_server::BasicHttpResponse>();
           http_response->set_code(net::HTTP_OK);
           http_response->set_content_type("application/json");
-          http_response->set_content(
+          std::string app_id = request.GetURL().Resolve("/index").spec();
+          http_response->set_content(base::StringPrintf(
               R"({
-                "https://foo.com/index": {}
-              })");
+                "%s": {}
+              })",
+              app_id.c_str()));
           return http_response;
         }
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/web_applications/jobs/manifest_to_web_app_install_info_job_unittest.cc b/chrome/browser/web_applications/jobs/manifest_to_web_app_install_info_job_unittest.cc
index f12c714ec..2ab33a2 100644
--- a/chrome/browser/web_applications/jobs/manifest_to_web_app_install_info_job_unittest.cc
+++ b/chrome/browser/web_applications/jobs/manifest_to_web_app_install_info_job_unittest.cc
@@ -712,6 +712,38 @@
             web_app_info->migration_sources[0].manifest_id().spec());
 }
 
+TEST_F(ManifestToWebAppInstallInfoJobTest,
+       ScopeExtensionsDropsNonHttpsAndOpaqueData) {
+  SetupBasicPageState();
+  auto& manifest = GetPageManifest();
+
+  {
+    auto scope_ext_http = blink::mojom::ManifestScopeExtension::New();
+    scope_ext_http->origin =
+        url::Origin::Create(GURL("http://insecure.example.com/"));
+    scope_ext_http->has_origin_wildcard = false;
+    manifest->scope_extensions.push_back(std::move(scope_ext_http));
+  }
+  {
+    auto scope_ext_opaque = blink::mojom::ManifestScopeExtension::New();
+    scope_ext_opaque->origin = url::Origin();
+    scope_ext_opaque->has_origin_wildcard = false;
+    manifest->scope_extensions.push_back(std::move(scope_ext_opaque));
+  }
+  {
+    auto scope_ext_valid = blink::mojom::ManifestScopeExtension::New();
+    scope_ext_valid->origin =
+        url::Origin::Create(GURL("https://secure.example.com/"));
+    scope_ext_valid->has_origin_wildcard = false;
+    manifest->scope_extensions.push_back(std::move(scope_ext_valid));
+  }
+
+  auto web_app_info = GetWebAppInstallInfoFromJob(*manifest);
+  ASSERT_EQ(1u, web_app_info->scope_extensions.size());
+  EXPECT_EQ(url::Origin::Create(GURL("https://secure.example.com/")),
+            web_app_info->scope_extensions.begin()->origin);
+}
+
 TEST_F(ManifestToWebAppInstallInfoJobTest, InvalidManifestUrl) {
   SetupBasicPageState();
   auto& manifest = GetPageManifest();
diff --git a/chrome/browser/web_applications/web_app_origin_association_manager_browsertest.cc b/chrome/browser/web_applications/web_app_origin_association_manager_browsertest.cc
index 703ca95..eb42c66 100644
--- a/chrome/browser/web_applications/web_app_origin_association_manager_browsertest.cc
+++ b/chrome/browser/web_applications/web_app_origin_association_manager_browsertest.cc
@@ -290,10 +290,12 @@
               std::make_unique<net::test_server::BasicHttpResponse>();
           http_response->set_code(net::HTTP_OK);
           http_response->set_content_type("application/json");
-          http_response->set_content(
+          std::string app_id = request.GetURL().Resolve("/index").spec();
+          http_response->set_content(base::StringPrintf(
               R"({
-                "https://foo.com/index": {}
-              })");
+                "%s": {}
+              })",
+              app_id.c_str()));
           return http_response;
         }
         return nullptr;
@@ -301,20 +303,43 @@
   ASSERT_TRUE(https_server.Start());
 
   GURL server_url = https_server.base_url();
+  GURL app_identity = https_server.GetURL("/index");
   ScopeExtensionInfo scope_extension =
       ScopeExtensionInfo::CreateForOrigin(url::Origin::Create(server_url));
 
   OriginAssociations origin_associations;
   origin_associations.scope_extensions = {scope_extension};
 
-  base::test::TestFuture<OriginAssociations> future;
-  manager_->GetWebAppOriginAssociations(GURL("https://foo.com/index"),
-                                        std::move(origin_associations),
-                                        future.GetCallback());
+  // 1. Same-address-space app can fetch from scope extension using the real
+  // StoragePartition URLLoaderFactory.
+  {
+    base::test::TestFuture<OriginAssociations> future;
+    manager_->GetWebAppOriginAssociations(app_identity, origin_associations,
+                                          future.GetCallback());
 
-  const OriginAssociations result = future.Get();
-  EXPECT_EQ(result.scope_extensions.size(), 1u);
-  EXPECT_EQ((*result.scope_extensions.begin()).origin, scope_extension.origin);
+    const OriginAssociations result = future.Get();
+    ASSERT_EQ(result.scope_extensions.size(), 1u);
+    EXPECT_EQ((*result.scope_extensions.begin()).origin,
+              scope_extension.origin);
+  }
+
+  // 2. Public app cannot fetch from private/link-local scope extensions (SSRF
+  // blocked).
+  {
+    OriginAssociations private_associations;
+    private_associations.scope_extensions = {
+        ScopeExtensionInfo::CreateForOrigin(
+            url::Origin::Create(GURL("https://192.168.1.10"))),
+        ScopeExtensionInfo::CreateForOrigin(
+            url::Origin::Create(GURL("https://169.254.169.254")))};
+    base::test::TestFuture<OriginAssociations> future;
+    manager_->GetWebAppOriginAssociations(GURL("https://foo.com/index"),
+                                          std::move(private_associations),
+                                          future.GetCallback());
+
+    const OriginAssociations result = future.Get();
+    EXPECT_TRUE(result.scope_extensions.empty());
+  }
 }
 
 IN_PROC_BROWSER_TEST_F(WebAppOriginAssociationManagerTest,
diff --git a/components/webapps/services/web_app_origin_association/test/test_web_app_origin_association_fetcher.cc b/components/webapps/services/web_app_origin_association/test/test_web_app_origin_association_fetcher.cc
index 3f43100..a270fbf 100644
--- a/components/webapps/services/web_app_origin_association/test/test_web_app_origin_association_fetcher.cc
+++ b/components/webapps/services/web_app_origin_association/test/test_web_app_origin_association_fetcher.cc
@@ -20,6 +20,7 @@
 
 void TestWebAppOriginAssociationFetcher::FetchWebAppOriginAssociationFile(
     const url::Origin& origin,
+    network::mojom::IPAddressSpace initiator_address_space,
     FetchFileCallback callback) {
   auto search = data_.find(origin);
   std::string file_content;
@@ -34,6 +35,13 @@
                          : std::make_optional(std::move(file_content))));
 }
 
+void TestWebAppOriginAssociationFetcher::FetchWebAppOriginAssociationFile(
+    const url::Origin& origin,
+    FetchFileCallback callback) {
+  FetchWebAppOriginAssociationFile(
+      origin, network::mojom::IPAddressSpace::kUnknown, std::move(callback));
+}
+
 void TestWebAppOriginAssociationFetcher::SetData(
     std::map<url::Origin, std::string> data) {
   data_ = std::move(data);
diff --git a/components/webapps/services/web_app_origin_association/test/test_web_app_origin_association_fetcher.h b/components/webapps/services/web_app_origin_association/test/test_web_app_origin_association_fetcher.h
index 1392077..e8d9e5b 100644
--- a/components/webapps/services/web_app_origin_association/test/test_web_app_origin_association_fetcher.h
+++ b/components/webapps/services/web_app_origin_association/test/test_web_app_origin_association_fetcher.h
@@ -22,6 +22,10 @@
   ~TestWebAppOriginAssociationFetcher() override;
 
   // WebAppOriginAssociationFetcher:
+  void FetchWebAppOriginAssociationFile(
+      const url::Origin& origin,
+      network::mojom::IPAddressSpace initiator_address_space,
+      FetchFileCallback callback) override;
   void FetchWebAppOriginAssociationFile(const url::Origin& origin,
                                         FetchFileCallback callback) override;
diff --git a/components/webapps/services/web_app_origin_association/web_app_origin_association_fetcher_unittest.cc b/components/webapps/services/web_app_origin_association/web_app_origin_association_fetcher_unittest.cc
index d4ca2bdf..c5ac603d 100644
--- a/components/webapps/services/web_app_origin_association/web_app_origin_association_fetcher_unittest.cc
+++ b/components/webapps/services/web_app_origin_association/web_app_origin_association_fetcher_unittest.cc
@@ -21,6 +21,7 @@
 #include "net/test/embedded_test_server/http_response.h"
 #include "services/network/network_service.h"
 #include "services/network/public/cpp/weak_wrapper_shared_url_loader_factory.h"
+#include "services/network/public/mojom/ip_address_space.mojom.h"
 #include "services/network/test/test_network_context_client.h"
 #include "services/network/test/test_shared_url_loader_factory.h"
 #include "services/network/test/test_url_loader_factory.h"
@@ -47,7 +48,8 @@
 
     shared_url_loader_factory_ =
         base::MakeRefCounted<network::TestSharedURLLoaderFactory>(
-            network::NetworkService::GetNetworkServiceForTesting());
+            network::NetworkService::GetNetworkServiceForTesting(),
+            /*is_trusted=*/true);
 
     fetcher_ = std::make_unique<WebAppOriginAssociationFetcher>(
         shared_url_loader_factory_);
@@ -71,6 +73,14 @@
     if (request.relative_url != "/.well-known/web-app-origin-association")
       return nullptr;
 
+    if (redirect_enabled_) {
+      auto redirect_response =
+          std::make_unique<net::test_server::BasicHttpResponse>();
+      redirect_response->set_code(net::HTTP_FOUND);
+      redirect_response->AddCustomHeader("Location", "/redirected");
+      return redirect_response;
+    }
+
     auto http_response =
         std::make_unique<net::test_server::BasicHttpResponse>();
     http_response->set_code(net::HTTP_OK);
@@ -85,12 +95,14 @@
   scoped_refptr<network::TestSharedURLLoaderFactory> shared_url_loader_factory_;
   std::unique_ptr<WebAppOriginAssociationFetcher> fetcher_;
   base::HistogramTester histogram_tester_;
+  bool redirect_enabled_ = false;
 };
 
 TEST_F(WebAppOriginAssociationFetcherTest, FileExists) {
   base::test::TestFuture<std::optional<std::string>> future;
   fetcher_->FetchWebAppOriginAssociationFile(
-      url::Origin::Create(GURL(server_.base_url())), future.GetCallback());
+      url::Origin::Create(GURL(server_.base_url())),
+      network::mojom::IPAddressSpace::kLoopback, future.GetCallback());
 
   auto file_content = future.Take();
   ASSERT_FALSE(!file_content);
@@ -127,6 +139,125 @@
       WebAppOriginAssociationMetrics::FetchResult::kFetchFailedInvalidUrl, 1);
 }
 
+TEST_F(WebAppOriginAssociationFetcherTest,
+       PublicInitiatorCannotFetchPrivateIp) {
+  base::test::TestFuture<std::optional<std::string>> future;
+  fetcher_->FetchWebAppOriginAssociationFile(
+      url::Origin::Create(GURL("https://192.168.1.1:8443")),
+      network::mojom::IPAddressSpace::kPublic, future.GetCallback());
+
+  auto file_content = future.Take();
+  ASSERT_FALSE(file_content.has_value());
+  histogram_tester_.ExpectBucketCount(
+      kFetchResultHistogram,
+      WebAppOriginAssociationMetrics::FetchResult::kFetchFailedInvalidUrl, 1);
+}
+
+TEST_F(WebAppOriginAssociationFetcherTest,
+       PublicInitiatorCannotFetchLoopbackIp) {
+  base::test::TestFuture<std::optional<std::string>> future;
+  fetcher_->FetchWebAppOriginAssociationFile(
+      url::Origin::Create(GURL("https://127.0.0.1:8443")),
+      network::mojom::IPAddressSpace::kPublic, future.GetCallback());
+
+  auto file_content = future.Take();
+  ASSERT_FALSE(file_content.has_value());
+  histogram_tester_.ExpectBucketCount(
+      kFetchResultHistogram,
+      WebAppOriginAssociationMetrics::FetchResult::kFetchFailedInvalidUrl, 1);
+}
+
+TEST_F(WebAppOriginAssociationFetcherTest, CannotFetchLinkLocalIp) {
+  base::test::TestFuture<std::optional<std::string>> future;
+  fetcher_->FetchWebAppOriginAssociationFile(
+      url::Origin::Create(GURL("https://169.254.169.254:8443")),
+      network::mojom::IPAddressSpace::kLocal, future.GetCallback());
+
+  auto file_content = future.Take();
+  ASSERT_FALSE(file_content.has_value());
+  histogram_tester_.ExpectBucketCount(
+      kFetchResultHistogram,
+      WebAppOriginAssociationMetrics::FetchResult::kFetchFailedInvalidUrl, 1);
+}
+
+TEST_F(WebAppOriginAssociationFetcherTest, CannotFetchMulticastOrZeroIp) {
+  {
+    base::test::TestFuture<std::optional<std::string>> future;
+    fetcher_->FetchWebAppOriginAssociationFile(
+        url::Origin::Create(GURL("https://0.0.0.0:8443")),
+        network::mojom::IPAddressSpace::kLocal, future.GetCallback());
+
+    auto file_content = future.Take();
+    ASSERT_FALSE(file_content.has_value());
+  }
+  {
+    base::test::TestFuture<std::optional<std::string>> future;
+    fetcher_->FetchWebAppOriginAssociationFile(
+        url::Origin::Create(GURL("https://224.0.0.1:8443")),
+        network::mojom::IPAddressSpace::kLocal, future.GetCallback());
+
+    auto file_content = future.Take();
+    ASSERT_FALSE(file_content.has_value());
+  }
+  histogram_tester_.ExpectBucketCount(
+      kFetchResultHistogram,
+      WebAppOriginAssociationMetrics::FetchResult::kFetchFailedInvalidUrl, 2);
+}
+
+TEST_F(WebAppOriginAssociationFetcherTest,
+       PublicInitiatorCannotFetchLocalhostDomain) {
+  base::test::TestFuture<std::optional<std::string>> future;
+  fetcher_->FetchWebAppOriginAssociationFile(
+      url::Origin::Create(GURL("https://localhost:8443")),
+      network::mojom::IPAddressSpace::kPublic, future.GetCallback());
+
+  auto file_content = future.Take();
+  ASSERT_FALSE(file_content.has_value());
+  histogram_tester_.ExpectBucketCount(
+      kFetchResultHistogram,
+      WebAppOriginAssociationMetrics::FetchResult::kFetchFailedInvalidUrl, 1);
+}
+
+TEST_F(WebAppOriginAssociationFetcherTest, InsecureHttpOriginIsRejected) {
+  base::test::TestFuture<std::optional<std::string>> future;
+  fetcher_->FetchWebAppOriginAssociationFile(
+      url::Origin::Create(GURL("http://example.com")),
+      network::mojom::IPAddressSpace::kPublic, future.GetCallback());
+
+  auto file_content = future.Take();
+  ASSERT_FALSE(file_content.has_value());
+  histogram_tester_.ExpectBucketCount(
+      kFetchResultHistogram,
... (truncated)
Loading diff…

Original Bug Report

reported by vm...@google.com

Blind SSRF and Local Network Port Scanning via PWA scope_extensions

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: The WebAppOriginAssociationFetcher fails to enforce Local Network Access (LNA) restrictions when fetching PWA scope_extensions. Because the browser fetches these origins sequentially from a sorted set, an attacker can use a timing side-channel to reliably probe internal network ports. Additionally, a compromised renderer can bypass renderer-side scheme checks to directly target local HTTP endpoints via Mojo.

Affected files:

  • components/webapps/services/web_app_origin_association/web_app_origin_association_fetcher.cc
  • chrome/browser/web_applications/jobs/manifest_to_web_app_install_info_job.cc
  • chrome/browser/web_applications/web_app_origin_association_task.cc
  • third_party/blink/renderer/modules/manifest/manifest_parser.cc

Estimated timestamp from git blame: 2025-06-26

Description

WebAppOriginAssociationFetcher runs in the highly privileged browser process and fetches .well-known/web-app-origin-association files for origins listed in a PWA manifest’s scope_extensions array. Multiple logic flaws combine to create a potential Blind SSRF and local network port-scanning vulnerability:

  1. Missing LNA Restrictions: In WebAppOriginAssociationFetcher::CreateRequester, the network::ResourceRequest is created without setting a client_security_state and without appending network::mojom::kURLLoadOptionBlockLocalRequest to the loader options. Consequently, the network service permits these requests to reach RFC1918 and loopback addresses.
  2. Explicit IP Allowance: ShouldFetchAssociationFile explicitly permits IP-literal hosts (resource_url.HostIsIPAddress()), bypassing the registry-controlled domain checks that restrict other origins.
  3. Sequential Fetching & Sorting: WebAppOriginAssociationManager::Task places the extracted origins into a std::set<url::Origin>. This sorts the origins predictably by port, then scheme, then host. The browser then iterates through this set, fetching the association files sequentially.
  4. Mojo Validation Bypass: The browser process (ToWebAppScopeExtensions) does not re-validate the scheme or address space of ManifestScopeExtensionPtr objects received from the renderer via Mojo. While the renderer normally requires HTTPS, a compromised renderer can send http://127.0.0.1 directly.

By leveraging the sorting and sequential fetching, a web attacker can bracket a target internal IP with their own domains to create a highly reliable timing side-channel.

Note: Our tooling agent does not have the ability to run code, so the following are suggested steps to trigger the vulnerability based on static analysis.

Potential Reproduction Steps

Scenario A: Malicious Web Page (Timing Side-Channel / Port Scan)

  1. An attacker hosts a PWA manifest on their server containing scope_extensions crafted to bracket an internal IP. Example:
    • https://0.attacker.com:8443
    • https://192.168.1.1:8443
    • https://z.attacker.com:8443
  2. The user visits the attacker’s site, triggering a PWA installation or an update check.
  3. The browser process sorts the origins (port, scheme, host) and fetches them sequentially.
  4. The attacker’s server receives the request for 0.attacker.com at time $T_0$.
  5. The browser attempts to connect to 192.168.1.1:8443. If the port is closed, the connection drops instantly (ERR_CONNECTION_REFUSED). If the port is filtered/dropped, the connection hangs until timeout.
  6. The browser then fetches z.attacker.com. The attacker’s server receives this request at time $T_1$.
  7. The attacker calculates $T_1 - T_0$ to determine the exact duration of the internal request, accurately inferring the reachability and state of the target internal port.

Scenario B: Compromised Renderer (Direct Local SSRF)

  1. An attacker compromises the renderer process.
  2. The attacker crafts a blink.mojom.ManifestManager IPC response containing a ManifestScopeExtension with the origin http://127.0.0.1:8080.
  3. The browser process receives the Mojo message and blindly initiates a GET request to http://127.0.0.1:8080/.well-known/web-app-origin-association, potentially interacting with unprotected local services.

Suggested Fix

  1. Block Local Requests: In WebAppOriginAssociationFetcher::CreateRequester, modify the SetURLLoaderFactoryOptions call to explicitly block local network access:
    url_loader->SetURLLoaderFactoryOptions(
        network::mojom::kURLLoadOptionBlockAllCookies |
        network::mojom::kURLLoadOptionBlockLocalRequest);
    
  2. Re-validate Mojo Inputs: In ManifestManagerHost or ToWebAppScopeExtensions, strictly re-validate that all provided scope_extensions use the HTTPS scheme and are not IP addresses (unless specifically intended/safe for PWAs).

Evaluated with Chrome root at commit: a9cbf6e8b275fe4147435aa905f3b7f5a656f5f0


Results from so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker