Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in Loader
DescriptionInappropriate implementation in Loader
ComponentLoader
Bug ClassLogic Error
Tracker513532735
Fix commit32db081c2216 (chromium/src) +160/-21
CISA KEVNot listed
CreditedGoogle
Disclosed2026-07-29

Changed Functions

FunctionChangeNotes
if
content/browser/web_package/signed_exchange_reporter.cc
modified
frame_tree_node_id_
content/browser/web_package/signed_exchange_reporter.cc
modified
network_anonymization_key_
content/browser/web_package/signed_exchange_reporter.cc
modified
StoragePartitionImpl
content/browser/web_package/signed_exchange_reporter.h
modified
UnderlyingNetworkContext
content/browser/web_package/signed_exchange_reporter_browsertest.cc
modified
if
content/browser/web_package/signed_exchange_reporter_browsertest.cc
modified

Files Changed

  • content/browser/web_package/signed_exchange_reporter.cc
  • content/browser/web_package/signed_exchange_reporter.h
  • content/browser/web_package/signed_exchange_reporter_browsertest.cc
From 32db081c22169fc1c7f379c01360a24b43c1d952 Mon Sep 17 00:00:00 2001
From: Kouhei Ueno <kouhei@chromium.org>
Date: Thu, 25 Jun 2026 21:33:46 -0700
Subject: [PATCH] [SXG] Resolve report StoragePartition at reporter creation

SignedExchangeReporter is created when the outer SXG response is
received but only queues its NEL report after asynchronous work
(certificate fetch, inner-body decode) completes. It previously
re-resolved the StoragePartition via the FrameTreeNode's
current_frame_host() at queue time, by which point the frame may have
committed a document in a different StoragePartition, so the report
could be sent to the wrong NetworkContext.

Resolve the StoragePartition once at construction (matching the
already-captured NetworkAnonymizationKey) and hold it via a weak
reference. Adds a test-only observation hook and a browser test that
swaps the frame across partitions while a reporter is outstanding.

TAG=agy
CONV=78281464-5ea8-4e38-94fb-a1a0de44eede

Bug: 513532735
Change-Id: Iebe2b48283e18557e21eeafb5cd3533ba26a9c73
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8009246
Commit-Queue: Kouhei Ueno <kouhei@chromium.org>
Reviewed-by: Martin Pan-Verde <thesalsa@google.com>
Cr-Commit-Position: refs/heads/main@{#1652894}
---

diff --git a/content/browser/web_package/signed_exchange_reporter.cc b/content/browser/web_package/signed_exchange_reporter.cc
index cc4bb5f8..1411ba0 100644
--- a/content/browser/web_package/signed_exchange_reporter.cc
+++ b/content/browser/web_package/signed_exchange_reporter.cc
@@ -9,13 +9,12 @@
 
 #include "base/functional/callback.h"
 #include "base/memory/ptr_util.h"
+#include "base/no_destructor.h"
 #include "content/browser/renderer_host/frame_tree_node.h"
+#include "content/browser/storage_partition_impl.h"
 #include "content/browser/web_package/signed_exchange_utils.h"
-#include "content/public/browser/browser_context.h"
 #include "content/public/browser/browser_task_traits.h"
 #include "content/public/browser/browser_thread.h"
-#include "content/public/browser/render_frame_host.h"
-#include "content/public/browser/site_instance.h"
 #include "content/public/browser/storage_partition.h"
 #include "net/base/ip_endpoint.h"
 #include "services/network/public/mojom/network_context.mojom.h"
@@ -124,22 +123,14 @@
 }
 
 void ReportResult(
-    FrameTreeNodeId frame_tree_node_id,
+    base::WeakPtr<StoragePartitionImpl> storage_partition,
     network::mojom::SignedExchangeReportPtr report,
     const net::NetworkAnonymizationKey& network_anonymization_key) {
-  FrameTreeNode* frame_tree_node =
-      FrameTreeNode::GloballyFindByID(frame_tree_node_id);
-  if (!frame_tree_node)
+  if (!storage_partition) {
     return;
-  RenderFrameHostImpl* frame_host = frame_tree_node->current_frame_host();
-  if (!frame_host)
-    return;
-  SiteInstance* site_instance = frame_host->GetSiteInstance();
-  DCHECK(site_instance);
-  StoragePartition* partition =
-      frame_host->GetBrowserContext()->GetStoragePartition(site_instance);
-  DCHECK(partition);
-  partition->GetNetworkContext()->QueueSignedExchangeReport(
+  }
+
+  storage_partition->GetNetworkContext()->QueueSignedExchangeReport(
       std::move(report), network_anonymization_key);
 }
 
@@ -169,8 +160,18 @@
     FrameTreeNodeId frame_tree_node_id)
     : report_(network::mojom::SignedExchangeReport::New()),
       request_start_(response.load_timing.request_start),
-      network_anonymization_key_(network_anonymization_key),
-      frame_tree_node_id_(frame_tree_node_id) {
+      network_anonymization_key_(network_anonymization_key) {
+  // Resolve the StoragePartition that performed the load now: the report is
+  // queued only after asynchronous work completes, by which time the
+  // FrameTreeNode's current document may have been replaced by one in a
+  // different StoragePartition.
+  if (FrameTreeNode* frame_tree_node =
+          FrameTreeNode::GloballyFindByID(frame_tree_node_id)) {
+    if (RenderFrameHostImpl* frame_host =
+            frame_tree_node->current_frame_host()) {
+      storage_partition_ = frame_host->GetStoragePartition()->GetWeakPtr();
+    }
+  }
   report_->outer_url = outer_url;
   report_->referrer = referrer;
   report_->server_ip_address = response.remote_endpoint.address();
@@ -221,7 +222,7 @@
     report_->elapsed_time = base::TimeTicks::Now() - request_start_;
   }
 
-  ReportResult(frame_tree_node_id_, std::move(report_),
+  ReportResult(storage_partition_, std::move(report_),
                network_anonymization_key_);
 }
 
@@ -230,7 +231,7 @@
   report_->success = false;
   report_->type = kSXGHeaderIntegrityMismatch;
   report_->elapsed_time = base::TimeDelta();
-  ReportResult(frame_tree_node_id_, std::move(report_),
+  ReportResult(storage_partition_, std::move(report_),
                network_anonymization_key_);
 }
 
diff --git a/content/browser/web_package/signed_exchange_reporter.h b/content/browser/web_package/signed_exchange_reporter.h
index 74b15bd..9b8be91 100644
--- a/content/browser/web_package/signed_exchange_reporter.h
+++ b/content/browser/web_package/signed_exchange_reporter.h
@@ -8,6 +8,7 @@
 #include <memory>
 #include <string>
 
+#include "base/memory/weak_ptr.h"
 #include "base/time/time.h"
 #include "content/browser/web_package/signed_exchange_error.h"
 #include "content/common/content_export.h"
@@ -20,6 +21,8 @@
 
 namespace content {
 
+class StoragePartitionImpl;
+
 // SignedExchangeReporter sends a signed exchange report for distributor when
 // the distributor of signed exchange has set Network Error Logging (NEL) policy
 // using HTTP header.
@@ -58,7 +61,7 @@
   network::mojom::SignedExchangeReportPtr report_;
   const base::TimeTicks request_start_;
   const net::NetworkAnonymizationKey network_anonymization_key_;
-  const FrameTreeNodeId frame_tree_node_id_;
+  base::WeakPtr<StoragePartitionImpl> storage_partition_;
   net::IPAddress cert_server_ip_address_;
 };
 
diff --git a/content/browser/web_package/signed_exchange_reporter_browsertest.cc b/content/browser/web_package/signed_exchange_reporter_browsertest.cc
new file mode 100644
index 0000000..c18d71a
--- /dev/null
+++ b/content/browser/web_package/signed_exchange_reporter_browsertest.cc
@@ -0,0 +1,134 @@
+// 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 "content/browser/web_package/signed_exchange_reporter.h"
+
+#include "base/run_loop.h"
+#include "base/test/scoped_feature_list.h"
+#include "content/browser/renderer_host/frame_tree_node.h"
+#include "content/browser/renderer_host/render_frame_host_impl.h"
+#include "content/browser/web_contents/web_contents_impl.h"
+#include "content/public/browser/storage_partition.h"
+#include "content/public/test/browser_test.h"
+#include "content/public/test/browser_test_utils.h"
+#include "content/public/test/content_browser_test.h"
+#include "content/public/test/content_browser_test_utils.h"
+#include "content/shell/browser/shell.h"
+#include "content/test/content_browser_test_utils_internal.h"
+#include "mojo/public/cpp/bindings/receiver.h"
+#include "net/base/network_anonymization_key.h"
+#include "net/dns/mock_host_resolver.h"
+#include "net/test/embedded_test_server/embedded_test_server.h"
+#include "services/network/public/cpp/features.h"
+#include "services/network/public/mojom/url_response_head.mojom.h"
+#include "services/network/test/test_network_context.h"
+#include "url/gurl.h"
+
+namespace content {
+
+namespace {
+
+class UnderlyingNetworkContext : public network::TestNetworkContext {
+ public:
+  explicit UnderlyingNetworkContext(base::OnceClosure quit_closure)
+      : quit_closure_(std::move(quit_closure)) {}
+
+  void QueueSignedExchangeReport(
+      network::mojom::SignedExchangeReportPtr report,
+      const net::NetworkAnonymizationKey& network_anonymization_key) override {
+    called_ = true;
+    if (quit_closure_) {
+      std::move(quit_closure_).Run();
+    }
+  }
+
+  bool called() const { return called_; }
+
+ private:
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/web_package/signed_exchange_reporter_browsertest.cc b/content/browser/web_package/signed_exchange_reporter_browsertest.cc
new file mode 100644
index 0000000..c18d71a
--- /dev/null
+++ b/content/browser/web_package/signed_exchange_reporter_browsertest.cc
@@ -0,0 +1,134 @@
+// 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 "content/browser/web_package/signed_exchange_reporter.h"
+
+#include "base/run_loop.h"
+#include "base/test/scoped_feature_list.h"
+#include "content/browser/renderer_host/frame_tree_node.h"
+#include "content/browser/renderer_host/render_frame_host_impl.h"
+#include "content/browser/web_contents/web_contents_impl.h"
+#include "content/public/browser/storage_partition.h"
+#include "content/public/test/browser_test.h"
+#include "content/public/test/browser_test_utils.h"
+#include "content/public/test/content_browser_test.h"
+#include "content/public/test/content_browser_test_utils.h"
+#include "content/shell/browser/shell.h"
+#include "content/test/content_browser_test_utils_internal.h"
+#include "mojo/public/cpp/bindings/receiver.h"
+#include "net/base/network_anonymization_key.h"
+#include "net/dns/mock_host_resolver.h"
+#include "net/test/embedded_test_server/embedded_test_server.h"
+#include "services/network/public/cpp/features.h"
+#include "services/network/public/mojom/url_response_head.mojom.h"
+#include "services/network/test/test_network_context.h"
+#include "url/gurl.h"
+
+namespace content {
+
+namespace {
+
+class UnderlyingNetworkContext : public network::TestNetworkContext {
+ public:
+  explicit UnderlyingNetworkContext(base::OnceClosure quit_closure)
+      : quit_closure_(std::move(quit_closure)) {}
+
+  void QueueSignedExchangeReport(
+      network::mojom::SignedExchangeReportPtr report,
+      const net::NetworkAnonymizationKey& network_anonymization_key) override {
+    called_ = true;
+    if (quit_closure_) {
+      std::move(quit_closure_).Run();
+    }
+  }
+
+  bool called() const { return called_; }
+
+ private:
+  bool called_ = false;
+  base::OnceClosure quit_closure_;
+};
+
+}  // namespace
+
+class SignedExchangeReporterBrowserTest : public ContentBrowserTest {
+ public:
+  SignedExchangeReporterBrowserTest() {
+    feature_list_.InitAndEnableFeature(network::features::kReporting);
+  }
+
+  void SetUpOnMainThread() override {
+    host_resolver()->AddRule("*", "127.0.0.1");
+    ContentBrowserTest::SetUpOnMainThread();
+  }
+
+ protected:
+  WebContentsImpl* web_contents() {
+    return static_cast<WebContentsImpl*>(shell()->web_contents());
+  }
+
+ private:
+  base::test::ScopedFeatureList feature_list_;
+};
+
+// SignedExchangeReporter is created when the outer signed-exchange response is
+// received, but the report itself is queued only after asynchronous work (e.g.
+// fetching the certificate, or decoding the inner body) completes. The frame
+// may have committed a cross-StoragePartition document in the interim. Verify
+// that the report is queued on the StoragePartition that performed the load.
+IN_PROC_BROWSER_TEST_F(SignedExchangeReporterBrowserTest,
+                       ReportQueuedOnOriginatingStoragePartition) {
+  ASSERT_TRUE(embedded_test_server()->Start());
+
+  // Map b.com to a non-default StoragePartition.
+  CustomStoragePartitionBrowserClient modified_client(GURL("http://b.com/"));
+
+  // Navigate the main frame to a.com (default StoragePartition).
+  const GURL a_url(embedded_test_server()->GetURL("a.com", "/title1.html"));
+  ASSERT_TRUE(NavigateToURL(shell(), a_url));
+  RenderFrameHostImpl* rfh_a = web_contents()->GetPrimaryMainFrame();
+  StoragePartition* partition_a = rfh_a->GetStoragePartition();
+  FrameTreeNode* frame_tree_node = rfh_a->frame_tree_node();
+
+  // Create the reporter as the prefetch / navigation handler would on receipt
+  // of the outer response.
+  auto response_head = network::mojom::URLResponseHead::New();
+  std::unique_ptr<SignedExchangeReporter> reporter =
+      SignedExchangeReporter::MaybeCreate(
+          embedded_test_server()->GetURL("a.com", "/test.sxg"), a_url.spec(),
+          *response_head, net::NetworkAnonymizationKey(),
+          frame_tree_node->frame_tree_node_id());
+  ASSERT_TRUE(reporter);
+
+  // Swap the FrameTreeNode's current RenderFrameHost to a document in the
+  // non-default StoragePartition while the reporter is still alive.
+  const GURL b_url(embedded_test_server()->GetURL("b.com", "/title1.html"));
+  ASSERT_TRUE(NavigateToURL(shell(), b_url));
+  RenderFrameHostImpl* rfh_b = web_contents()->GetPrimaryMainFrame();
+  StoragePartition* partition_b = rfh_b->GetStoragePartition();
+  ASSERT_NE(partition_a, partition_b);
+  ASSERT_EQ(frame_tree_node, rfh_b->frame_tree_node());
+
+  // Set up mock NetworkContexts for both partitions to observe where the
+  // report is queued.
+  base::RunLoop run_loop;
+  UnderlyingNetworkContext context_a(run_loop.QuitClosure());
+  mojo::Receiver<network::mojom::NetworkContext> receiver_a(&context_a);
+  partition_a->SetNetworkContextForTesting(
+      receiver_a.BindNewPipeAndPassRemote());
+
+  UnderlyingNetworkContext context_b(base::NullCallback());
+  mojo::Receiver<network::mojom::NetworkContext> receiver_b(&context_b);
+  partition_b->SetNetworkContextForTesting(
+      receiver_b.BindNewPipeAndPassRemote());
+
+  // Finish the load and wait for the report to be queued.
+  reporter->ReportLoadResultAndFinish(SignedExchangeLoadResult::kSuccess);
+  run_loop.Run();
+
+  EXPECT_TRUE(context_a.called());
+  EXPECT_FALSE(context_b.called());
+}
+
+}  // namespace content
diff --git a/content/test/BUILD.gn b/content/test/BUILD.gn
index 4df012d..09561b27 100644
--- a/content/test/BUILD.gn
+++ b/content/test/BUILD.gn
@@ -1816,6 +1816,7 @@
     "../browser/web_contents/opened_by_dom_browsertest.cc",
     "../browser/web_contents/web_contents_impl_browsertest.cc",
     "../browser/web_contents/web_contents_observer_browsertest.cc",
+    "../browser/web_package/signed_exchange_reporter_browsertest.cc",
     "../browser/web_package/signed_exchange_request_handler_browsertest.cc",
     "../browser/web_package/signed_exchange_subresource_prefetch_browsertest.cc",
     "../browser/webid/delegation/jwt_signer_browsertest.cc",
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential StoragePartition isolation bypass in SignedExchangeReporter

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: SignedExchangeReporter::ReportResult re-resolves the StoragePartition for a report after an asynchronous delay, potentially routing it to the wrong NetworkContext. This occurs if a navigation commits and changes the frame’s StoragePartition before the Signed Exchange validation completes. An attacker could leverage this to leak metadata across isolated storage boundaries.

Affected files:

  • content/browser/web_package/signed_exchange_reporter.cc
  • content/browser/web_package/signed_exchange_loader.cc

Estimated timestamp from git blame: Unknown (Google3 checkout)

Summary

A logic bug in SignedExchangeReporter::ReportResult allows Signed Exchange (SXG) load reports to be incorrectly routed across StoragePartition boundaries. The issue stems from re-resolving the RenderFrameHost (and thus the StoragePartition) using a FrameTreeNodeId after asynchronous operations (such as certificate fetching) have completed. If the frame has navigated to a different StoragePartition during this interval, the report is dispatched to the NetworkContext of the new partition instead of the one that initiated the load.

Root Cause Analysis

In content/browser/web_package/signed_exchange_reporter.cc, the ReportResult function is responsible for queuing Network Error Logging (NEL) reports for SXGs:

void ReportResult(
    FrameTreeNodeId frame_tree_node_id,
    network::mojom::SignedExchangeReportPtr report,
    const net::NetworkAnonymizationKey& network_anonymization_key) {
  FrameTreeNode* frame_tree_node =
      FrameTreeNode::GloballyFindByID(frame_tree_node_id);
  if (!frame_tree_node)
    return;
  // Potential Vulnerability: Re-resolving the current frame host after an async delay.
  RenderFrameHostImpl* frame_host = frame_tree_node->current_frame_host(); 
  if (!frame_host)
    return;
  SiteInstance* site_instance = frame_host->GetSiteInstance();
  StoragePartition* partition =
      frame_host->GetBrowserContext()->GetStoragePartition(site_instance);
  partition->GetNetworkContext()->QueueSignedExchangeReport(
      std::move(report), network_anonymization_key);
}

When a Signed Exchange is processed, the reporter is created and stores the FrameTreeNodeId. However, SXG validation involves asynchronous steps like fetching the certificate chain. If the frame navigates to a URL in a different StoragePartition (e.g., from a Chrome App <webview> to a regular web origin) while validation is stalled, frame_tree_node->current_frame_host() will return the new host in the new partition. Consequently, the report—containing metadata and the NetworkAnonymizationKey from the original partition—is injected into the new partition’s NetworkContext.

Potential Impact

This issue represents a failure of StoragePartition isolation. An attacker could use this to leak load existence, timing information, and metadata (such as the Referer header) across partitions. For example, an attacker-controlled distributor could correlate a user’s activity in an isolated guest partition with their activity in a standard browsing session.

While the reports do not include cookies, the routing of telemetry through the incorrect NetworkContext is an architectural isolation failure and a “confused deputy” pattern where a navigation influences the destination of browser-side reporting.

Suggested Reproductions Steps (Potential)

  1. Open a document in an isolated StoragePartition (e.g., a <webview> guest).
  2. Initiate a prefetch for a Signed Exchange from an attacker-controlled distributor.
  3. The attacker’s server should stall the certificate fetch request defined in the SXG envelope.
  4. While the fetch is stalled, navigate the same frame to a URL mapping to a different StoragePartition (e.g., a standard web origin).
  5. Allow the certificate fetch to complete.
  6. Observe that the resulting NEL report is processed by the NetworkContext of the second partition (this can be verified via net-internals or by monitoring the attacker’s NEL reporting endpoint if a policy is present in the second partition).

The SignedExchangeReporter should not re-resolve the StoragePartition from the FrameTreeNode at the time of reporting. Instead, it should capture a reference to the appropriate StoragePartition or its NetworkContext during construction, ensuring the report is always routed to the partition that initiated the load.

Evaluated with Chrome root at commit: b3153093eb3c78c3e88ccf562bcbc20437a04b0e


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

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