CVE-2026-87477
Overview
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:[email protected]/index.html#section1 and an LCP URL are reduced to their credential- and fragment-free forms.
network::SerializeResponseUrlForReporting() to strip credentials and fragments.Attack Path
- Configure a reporting endpoint
A page enrolls a
DeclarativePerformanceObserverpolicy with a reporting endpoint and entry types such askNavigationorkLargestContentfulPaint. - 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). - Verbatim capture
The observer stores the raw URL in
committed_url_(initial nav or BFCache restore) or writes the rawlcp->urlinto the report-body dict, leaving credentials and fragment intact. - 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
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fcontent/browser/declarative_performance_observer/declarative_performance_observer_unittest.cc |
modified |
Files Changed
content/browser/declarative_performance_observer/declarative_performance_observer_unittest.cccontent/browser/declarative_performance_observer/declarative_performance_observer.cc
Audit Directions
- Report/telemetry body fields vs. top-level sanitizationAudit 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 outputReview code that copies
navigation_handle->GetURL()or resource URLs straight into dictionaries/JSON sent off-process or to endpoints withoutSerializeResponseUrlForReporting(or equivalent credential/fragment stripping). - BFCache and alternate entry pathsCheck 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.
Patch
From f5383966e92c1b7718d11931f3e471c56b7b9c5d Mon Sep 17 00:00:00 2001 From: Shunya Shishido <[email protected]> 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 <[email protected]> Commit-Queue: Shunya Shishido <[email protected]> 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:[email protected]/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:[email protected]/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:[email protected]/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
Regression Test / PoC
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:[email protected]/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:[email protected]/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:[email protected]/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