Low watch CVSS 6.5 chrome Info Disclosure 🔧 Commit mapped

Overview

Low
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInformation leak in Core
DescriptionInformation leak in Core
ComponentCore
Bug ClassInfo Disclosure
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker540059211
Fix commitf5383966e92c (chromium/src) +140/-2
CWECWE-200 (Information exposure)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-07-28 (per vendor advisory)
Fix landed2026-08-03 (public git)
Disclosed2026-09-08
Patch gap36 days — fix public in source before the advisory

Background

`DeclarativePerformanceObserver`
a browser-process observer that collects performance entries (navigation, LCP, etc.) and packages them into Reporting-API report bodies sent to a configured endpoint.
Reporting API report body
the JSON payload delivered to a reporting endpoint; unlike the top-level report URL, its body fields were not being sanitized.
`network::SerializeResponseUrlForReporting`
a helper that strips sensitive URL components (fragment and user credentials) to produce a report-safe URL string.
`committed_url_` / LCP `url`
the navigation URL stored by the observer and the Largest Contentful Paint resource URL, both previously serialized verbatim into report bodies.

Root Cause Analysis

DeclarativePerformanceObserver copied navigation_handle->GetURL() directly into committed_url_ (both on initial navigation and on BackForwardCache restoration) and wrote lcp->url verbatim into the report body dictionary in DidObservePerformanceEntries. While ReportingServiceImpl::QueueReport() sanitizes the top-level report URL by stripping fragments and user credentials, these body fields were transmitted unmodified, so a navigation or LCP URL containing a fragment or embedded user:password@ credentials was leaked to the reporting endpoint.

The fix wraps each assignment in network::SerializeResponseUrlForReporting(): committed_url_ is set from the sanitized URL in both the initial and BFCache-restore paths, and the LCP url is sanitized (when non-empty and valid) before being stored in the dict, falling back to the raw value only when the GURL is invalid. This restores the invariant that report bodies, like report URLs, never carry fragments or credentials to a third-party endpoint.

The added tests confirm https://user:password@example.com/index.html#section1 and an LCP URL are reduced to their credential- and fragment-free forms.

Key insight
The single mistake was serializing raw navigation and LCP URLs into Reporting-API report bodies while only the top-level report URL was sanitized; the fix routes all three body-URL assignments through network::SerializeResponseUrlForReporting() to strip credentials and fragments.

Attack Path

  1. Configure a reporting endpoint A page enrolls a DeclarativePerformanceObserver policy with a reporting endpoint and entry types such as kNavigation or kLargestContentfulPaint.
  2. Introduce sensitive URL components Navigation occurs to, or an LCP resource is loaded from, a URL containing user credentials (user:password@) and/or a fragment (#section).
  3. Verbatim capture The observer stores the raw URL in committed_url_ (initial nav or BFCache restore) or writes the raw lcp->url into the report-body dict, leaving credentials and fragment intact.
  4. Leak to endpoint The report body is queued and delivered to the reporting endpoint, disclosing the embedded credentials and fragment that top-level sanitization would have removed.

Impact Assessment

An attacker (or any observing reporting endpoint) receives navigation and LCP URLs in report bodies with user credentials and URL fragments intact, disclosing information that should have been stripped before transmission. The advisory classifies this as an information leak in Core; the leaked data is limited to what appears in these URL fields (credentials and fragments), and no memory-safety or escalation impact is indicated. Preconditions are an active reporting policy for the relevant entry types and navigation/LCP URLs that actually contain credentials or fragments.

Changed Functions

FunctionChangeNotes
TEST_F
content/browser/declarative_performance_observer/declarative_performance_observer_unittest.cc
modified

Files Changed

  • content/browser/declarative_performance_observer/declarative_performance_observer_unittest.cc
  • content/browser/declarative_performance_observer/declarative_performance_observer.cc

Audit Directions

  • Report/telemetry body fields vs. top-level sanitization
    Audit other Reporting-API, NEL, and telemetry producers to confirm URLs placed in report bodies are sanitized, not just the top-level report URL that QueueReport() handles.
  • Raw `GetURL()`/resource URLs into serialized output
    Review code that copies navigation_handle->GetURL() or resource URLs straight into dictionaries/JSON sent off-process or to endpoints without SerializeResponseUrlForReporting (or equivalent credential/fragment stripping).
  • BFCache and alternate entry paths
    Check that secondary lifecycle paths (BFCache restore, prerender activation) apply the same sanitization as the primary navigation path, since the BFCache path here originally missed it.
From f5383966e92c1b7718d11931f3e471c56b7b9c5d Mon Sep 17 00:00:00 2001
From: Shunya Shishido <sisidovski@chromium.org>
Date: Mon, 03 Aug 2026 02:59:38 -0700
Subject: [PATCH] Sanitize URLs in DeclarativePerformanceObserver report bodies

In DeclarativePerformanceObserver, navigation URLs and Largest
Contentful Paint (LCP) resource URLs were previously serialized verbatim
into performance entry dictionaries within Reporting-API report bodies.
While ReportingServiceImpl::QueueReport() sanitizes top-level report
URLs by stripping fragments and user credentials, report bodies were
transmitted unmodified.

This change wraps URL assignments in
network::SerializeResponseUrlForReporting() when initializing and
updating committed_url_ from navigation handles (during initial
navigation and BackForwardCache restoration), as well as when formatting
LCP resource URLs in DidObservePerformanceEntries().

TAG=agy
CONV=0d216dbe-f50c-4f85-a83b-851e23cebd72

Bug: 540059211
Change-Id: I24f0e23291232f1aab23023db36fab0e47253a19
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8177145
Reviewed-by: Yoshisato Yanagisawa <yyanagisawa@chromium.org>
Commit-Queue: Shunya Shishido <sisidovski@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1672507}
---
diff --git a/content/browser/declarative_performance_observer/declarative_performance_observer.cc b/content/browser/declarative_performance_observer/declarative_performance_observer.cc
index 59c9da2..d958fe1 100644
--- a/content/browser/declarative_performance_observer/declarative_performance_observer.cc
+++ b/content/browser/declarative_performance_observer/declarative_performance_observer.cc
@@ -18,6 +18,7 @@
 #include "mojo/public/cpp/bindings/message.h"
 #include "net/base/load_timing_info.h"
 #include "net/base/net_errors.h"
+#include "services/network/public/cpp/url_util.h"
 #include "services/network/public/mojom/network_context.mojom.h"
 #include "third_party/blink/public/common/features.h"
 #include "third_party/blink/public/mojom/use_counter/metrics/web_feature.mojom.h"
@@ -51,7 +52,8 @@
   }
 
   navigation_start_ = navigation_handle->NavigationStart();
-  committed_url_ = navigation_handle->GetURL();
+  committed_url_ =
+      network::SerializeResponseUrlForReporting(navigation_handle->GetURL());
 
   network_anonymization_key_ =
       rfh->GetIsolationInfoForSubresources().network_anonymization_key();
@@ -151,6 +153,8 @@
   DCHECK(navigation_handle->IsServedFromBackForwardCache());
 
   navigation_start_ = navigation_handle->NavigationStart();
+  committed_url_ =
+      network::SerializeResponseUrlForReporting(navigation_handle->GetURL());
   buffered_entries_.clear();
   current_buffer_bytes_ = 0;
 
@@ -369,7 +373,16 @@
         dict.Set("renderTime", lcp->render_time.InMillisecondsF());
         dict.Set("loadTime", lcp->load_time.InMillisecondsF());
         dict.Set("id", lcp->id.value_or(""));
-        dict.Set("url", lcp->url.value_or(""));
+        std::string lcp_url = "";
+        if (lcp->url.has_value() && !lcp->url->empty()) {
+          GURL url(lcp->url.value());
+          if (url.is_valid()) {
+            lcp_url = network::SerializeResponseUrlForReporting(url).spec();
+          } else {
+            lcp_url = lcp->url.value();
+          }
+        }
+        dict.Set("url", lcp_url);
         dict.Set("element", lcp->element.value_or(""));
 
         AddEntryToBuffer(std::move(dict));
diff --git a/content/browser/declarative_performance_observer/declarative_performance_observer_unittest.cc b/content/browser/declarative_performance_observer/declarative_performance_observer_unittest.cc
index b2f5001..37cc7f61 100644
--- a/content/browser/declarative_performance_observer/declarative_performance_observer_unittest.cc
+++ b/content/browser/declarative_performance_observer/declarative_performance_observer_unittest.cc
@@ -1353,5 +1353,130 @@
   SetBrowserClientForTesting(old_client);
 }
 
+TEST_F(DeclarativePerformanceObserverTest,
+       SanitizesReportUrlsInNavigationEntry) {
+  const GURL kPageURL("https://user:password@example.com/index.html#section1");
+  const GURL kExpectedSanitizedURL("https://example.com/index.html");
+  const std::string kEndpoint("telemetry");
+
+  auto policy = network::mojom::DeclarativePerformanceObserverPolicy::New();
+  policy->reporting_endpoint = kEndpoint;
+  policy->entry_types.push_back(
+      network::mojom::PerformanceEntryType::kNavigation);
+
+  MockNavigationHandle navigation_handle(kPageURL, main_rfh());
+  navigation_handle.set_has_committed(true);
+  navigation_handle.set_is_in_primary_main_frame(true);
+  navigation_handle.set_is_error_page(false);
+
+  NavigationHandleTiming timing;
+  ON_CALL(navigation_handle, GetNavigationHandleTiming())
+      .WillByDefault(testing::ReturnRef(timing));
+
+  ON_CALL(navigation_handle, GetDeclarativePerformanceObserverPolicy())
+      .WillByDefault(testing::Return(policy.get()));
+
+  CreateObserver(&navigation_handle);
+  auto* observer =
+      DeclarativePerformanceObserver::GetForCurrentDocument(main_rfh());
+  ASSERT_TRUE(observer);
+
+  observer->OnEnterBFCache();
+
+  ASSERT_EQ(network_context_.reports().size(), 1u);
+  const auto& report1 = network_context_.reports()[0];
+  EXPECT_EQ(report1.type, "performance-observer");
+
+  const base::ListValue* entries1 = report1.body.FindList("entries");
+  ASSERT_TRUE(entries1);
+  ASSERT_GE(entries1->size(), 1u);
+
+  const base::DictValue* nav_entry1 = (*entries1)[0].GetIfDict();
+  ASSERT_TRUE(nav_entry1);
+  EXPECT_EQ(*(nav_entry1->FindString("entryType")), "navigation");
+  EXPECT_EQ(*(nav_entry1->FindString("name")), kExpectedSanitizedURL.spec());
+
+  network_context_.ClearReports();
+
+  const GURL kBFCacheURL(
+      "https://user:password@example.com/index.html#section2");
+  MockNavigationHandle restore_handle(kBFCacheURL, main_rfh());
+  restore_handle.set_has_committed(true);
+  restore_handle.set_is_in_primary_main_frame(true);
+  restore_handle.set_is_error_page(false);
+  restore_handle.set_is_served_from_bfcache(true);
+
+  observer->OnDidFinishNavigation(&restore_handle);
+
+  DeclarativePerformanceObserver::DeleteForCurrentDocument(main_rfh());
+
+  ASSERT_EQ(network_context_.reports().size(), 1u);
+  const auto& report2 = network_context_.reports()[0];
+  const base::ListValue* entries2 = report2.body.FindList("entries");
+  ASSERT_TRUE(entries2);
+  ASSERT_GE(entries2->size(), 1u);
+
+  const base::DictValue* nav_entry2 = (*entries2)[0].GetIfDict();
+  ASSERT_TRUE(nav_entry2);
+  EXPECT_EQ(*(nav_entry2->FindString("entryType")), "navigation");
+  EXPECT_EQ(*(nav_entry2->FindString("type")), "back_forward");
+  EXPECT_EQ(*(nav_entry2->FindString("name")), kExpectedSanitizedURL.spec());
+}
+
+TEST_F(DeclarativePerformanceObserverTest, SanitizesReportUrlsInLcpEntry) {
+  const GURL kPageURL("https://example.com/index.html");
+  const std::string kEndpoint("telemetry");
+
+  auto policy = network::mojom::DeclarativePerformanceObserverPolicy::New();
+  policy->reporting_endpoint = kEndpoint;
+  policy->entry_types.push_back(
+      network::mojom::PerformanceEntryType::kLargestContentfulPaint);
+
+  MockNavigationHandle navigation_handle(kPageURL, main_rfh());
+  navigation_handle.set_has_committed(true);
+  navigation_handle.set_is_in_primary_main_frame(true);
+  navigation_handle.set_is_error_page(false);
+
+  NavigationHandleTiming timing;
+  ON_CALL(navigation_handle, GetNavigationHandleTiming())
+      .WillByDefault(testing::ReturnRef(timing));
+
+  ON_CALL(navigation_handle, GetDeclarativePerformanceObserverPolicy())
+      .WillByDefault(testing::Return(policy.get()));
+
+  CreateObserver(&navigation_handle);
+
+  mojo::Remote<blink::mojom::DeclarativePerformanceObserverHost>
+      observer_remote;
+  DeclarativePerformanceObserver::Bind(
+      main_rfh(), observer_remote.BindNewPipeAndPassReceiver());
+
+  std::vector<blink::mojom::DeclarativePerformanceEntryPtr> entries;
+  entries.push_back(blink::mojom::DeclarativePerformanceEntry::NewLcp(
+      blink::mojom::DeclarativeLargestContentfulPaint::New(
+          base::Milliseconds(150), 450, base::Milliseconds(150),
+          base::Milliseconds(120), "hero-img",
+          "https://user:password@example.com/hero.png#section2", "IMG")));
+  observer_remote->DidObservePerformanceEntries(std::move(entries));
+  observer_remote.FlushForTesting();
+
+  DeclarativePerformanceObserver::DeleteForCurrentDocument(main_rfh());
+
+  ASSERT_EQ(network_context_.reports().size(), 1u);
+  const auto& report = network_context_.reports()[0];
+
+  const base::ListValue* entries_list = report.body.FindList("entries");
+  ASSERT_TRUE(entries_list);
+  ASSERT_GE(entries_list->size(), 1u);
+
+  const base::Value& entry_val0 = (*entries_list)[0];
+  const base::DictValue* lcp_entry = entry_val0.GetIfDict();
+  ASSERT_TRUE(lcp_entry);
+
+  const std::string* url = lcp_entry->FindString("url");
+  ASSERT_TRUE(url);
+  EXPECT_EQ(*url, "https://example.com/hero.png");
+}
+
 }  // namespace
 }  // namespace content
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/declarative_performance_observer/declarative_performance_observer_unittest.cc b/content/browser/declarative_performance_observer/declarative_performance_observer_unittest.cc
index b2f5001..37cc7f61 100644
--- a/content/browser/declarative_performance_observer/declarative_performance_observer_unittest.cc
+++ b/content/browser/declarative_performance_observer/declarative_performance_observer_unittest.cc
@@ -1353,5 +1353,130 @@
   SetBrowserClientForTesting(old_client);
 }
 
+TEST_F(DeclarativePerformanceObserverTest,
+       SanitizesReportUrlsInNavigationEntry) {
+  const GURL kPageURL("https://user:password@example.com/index.html#section1");
+  const GURL kExpectedSanitizedURL("https://example.com/index.html");
+  const std::string kEndpoint("telemetry");
+
+  auto policy = network::mojom::DeclarativePerformanceObserverPolicy::New();
+  policy->reporting_endpoint = kEndpoint;
+  policy->entry_types.push_back(
+      network::mojom::PerformanceEntryType::kNavigation);
+
+  MockNavigationHandle navigation_handle(kPageURL, main_rfh());
+  navigation_handle.set_has_committed(true);
+  navigation_handle.set_is_in_primary_main_frame(true);
+  navigation_handle.set_is_error_page(false);
+
+  NavigationHandleTiming timing;
+  ON_CALL(navigation_handle, GetNavigationHandleTiming())
+      .WillByDefault(testing::ReturnRef(timing));
+
+  ON_CALL(navigation_handle, GetDeclarativePerformanceObserverPolicy())
+      .WillByDefault(testing::Return(policy.get()));
+
+  CreateObserver(&navigation_handle);
+  auto* observer =
+      DeclarativePerformanceObserver::GetForCurrentDocument(main_rfh());
+  ASSERT_TRUE(observer);
+
+  observer->OnEnterBFCache();
+
+  ASSERT_EQ(network_context_.reports().size(), 1u);
+  const auto& report1 = network_context_.reports()[0];
+  EXPECT_EQ(report1.type, "performance-observer");
+
+  const base::ListValue* entries1 = report1.body.FindList("entries");
+  ASSERT_TRUE(entries1);
+  ASSERT_GE(entries1->size(), 1u);
+
+  const base::DictValue* nav_entry1 = (*entries1)[0].GetIfDict();
+  ASSERT_TRUE(nav_entry1);
+  EXPECT_EQ(*(nav_entry1->FindString("entryType")), "navigation");
+  EXPECT_EQ(*(nav_entry1->FindString("name")), kExpectedSanitizedURL.spec());
+
+  network_context_.ClearReports();
+
+  const GURL kBFCacheURL(
+      "https://user:password@example.com/index.html#section2");
+  MockNavigationHandle restore_handle(kBFCacheURL, main_rfh());
+  restore_handle.set_has_committed(true);
+  restore_handle.set_is_in_primary_main_frame(true);
+  restore_handle.set_is_error_page(false);
+  restore_handle.set_is_served_from_bfcache(true);
+
+  observer->OnDidFinishNavigation(&restore_handle);
+
+  DeclarativePerformanceObserver::DeleteForCurrentDocument(main_rfh());
+
+  ASSERT_EQ(network_context_.reports().size(), 1u);
+  const auto& report2 = network_context_.reports()[0];
+  const base::ListValue* entries2 = report2.body.FindList("entries");
+  ASSERT_TRUE(entries2);
+  ASSERT_GE(entries2->size(), 1u);
+
+  const base::DictValue* nav_entry2 = (*entries2)[0].GetIfDict();
+  ASSERT_TRUE(nav_entry2);
+  EXPECT_EQ(*(nav_entry2->FindString("entryType")), "navigation");
+  EXPECT_EQ(*(nav_entry2->FindString("type")), "back_forward");
+  EXPECT_EQ(*(nav_entry2->FindString("name")), kExpectedSanitizedURL.spec());
+}
+
+TEST_F(DeclarativePerformanceObserverTest, SanitizesReportUrlsInLcpEntry) {
+  const GURL kPageURL("https://example.com/index.html");
+  const std::string kEndpoint("telemetry");
+
+  auto policy = network::mojom::DeclarativePerformanceObserverPolicy::New();
+  policy->reporting_endpoint = kEndpoint;
+  policy->entry_types.push_back(
+      network::mojom::PerformanceEntryType::kLargestContentfulPaint);
+
+  MockNavigationHandle navigation_handle(kPageURL, main_rfh());
+  navigation_handle.set_has_committed(true);
+  navigation_handle.set_is_in_primary_main_frame(true);
+  navigation_handle.set_is_error_page(false);
+
+  NavigationHandleTiming timing;
+  ON_CALL(navigation_handle, GetNavigationHandleTiming())
+      .WillByDefault(testing::ReturnRef(timing));
+
+  ON_CALL(navigation_handle, GetDeclarativePerformanceObserverPolicy())
+      .WillByDefault(testing::Return(policy.get()));
+
+  CreateObserver(&navigation_handle);
+
+  mojo::Remote<blink::mojom::DeclarativePerformanceObserverHost>
+      observer_remote;
+  DeclarativePerformanceObserver::Bind(
+      main_rfh(), observer_remote.BindNewPipeAndPassReceiver());
+
+  std::vector<blink::mojom::DeclarativePerformanceEntryPtr> entries;
+  entries.push_back(blink::mojom::DeclarativePerformanceEntry::NewLcp(
+      blink::mojom::DeclarativeLargestContentfulPaint::New(
+          base::Milliseconds(150), 450, base::Milliseconds(150),
+          base::Milliseconds(120), "hero-img",
+          "https://user:password@example.com/hero.png#section2", "IMG")));
+  observer_remote->DidObservePerformanceEntries(std::move(entries));
+  observer_remote.FlushForTesting();
+
+  DeclarativePerformanceObserver::DeleteForCurrentDocument(main_rfh());
+
+  ASSERT_EQ(network_context_.reports().size(), 1u);
+  const auto& report = network_context_.reports()[0];
+
+  const base::ListValue* entries_list = report.body.FindList("entries");
+  ASSERT_TRUE(entries_list);
+  ASSERT_GE(entries_list->size(), 1u);
+
+  const base::Value& entry_val0 = (*entries_list)[0];
+  const base::DictValue* lcp_entry = entry_val0.GetIfDict();
+  ASSERT_TRUE(lcp_entry);
+
+  const std::string* url = lcp_entry->FindString("url");
+  ASSERT_TRUE(url);
+  EXPECT_EQ(*url, "https://example.com/hero.png");
+}
+
 }  // namespace
 }  // namespace content
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.