CVE-2026-11194
Overview
Files Changed
content/browser/network/cross_origin_embedder_policy_reporter.cccontent/browser/network/cross_origin_embedder_policy_reporter_unittest.cc
Patch
From 59bf0e200cae8f2ac8f62fc4ef251be67d426e15 Mon Sep 17 00:00:00 2001 From: Andrew Paseltiner <[email protected]> Date: Thu, 30 Apr 2026 10:40:12 -0700 Subject: [PATCH] Strip fragments and credentials from COEP and DIP violation reports The CrossOriginEmbedderPolicyReporter and DocumentIsolationPolicyReporter were incorrectly preserving URL fragments in the blockedURL field of violation reports. This violates the specifications and can leak sensitive information. This CL introduces network::SerializeResponseUrlForReporting, which correctly strips fragments, usernames, and passwords from URLs for reporting purposes, following the Fetch specification. Spec links: - Fetch: https://fetch.spec.whatwg.org/#serialize-a-response-url-for-reporting - COEP: https://html.spec.whatwg.org/multipage/browsers.html#queue-a-cross-origin-embedder-policy-inheritance-violation - DIP: https://wicg.github.io/document-isolation-policy/#queue-a-document-isolation-policy-corp-violation-report The new function uses GURL::Replacements instead of GURL::GetAsReferrer() to ensure that local schemes like data: and blob: are supported. Using GetAsReferrer() would sanitize such URLs to empty strings, which caused regressions in COEP WPTs. Note: CrossOriginOpenerPolicyReporter currently uses GetAsReferrer() via a local SanitizedURL helper. While the COOP specification (specifically the "sanitize a URL for a report" algorithm [1]) does not have scheme restrictions and thus should support non-referrer schemes, this CL intentionally leaves COOP unchanged for now to maintain existing behavior and avoid unintentional side effects, as COOP's scheme constraints are currently different in implementation and consistency with the net/ reporting service [2] is currently preserved there. Both COOP and the general Reporting service currently deviate from their respective specifications by using GetAsReferrer(), which rejects non-referrer schemes like data: and blob:. This CL documents these discrepancies while ensuring COEP and DIP are spec-compliant. [1] https://html.spec.whatwg.org/multipage/browsers.html#sanitize-url-report [2] https://w3c.github.io/reporting/#strip-url-for-use-in-reports Fixed: 503719488 Force-WPT-Export: True Change-Id: Id14221951618e4f37512beb82ac5f10642f13760 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7790791 Reviewed-by: Camille Lamy <[email protected]> Commit-Queue: Andrew Paseltiner <[email protected]> Reviewed-by: Eric Orth <[email protected]> Cr-Commit-Position: refs/heads/main@{#1623313} --- diff --git a/content/browser/network/cross_origin_embedder_policy_reporter.cc b/content/browser/network/cross_origin_embedder_policy_reporter.cc index 2c8d004..765eb50 100644 --- a/content/browser/network/cross_origin_embedder_policy_reporter.cc +++ b/content/browser/network/cross_origin_embedder_policy_reporter.cc @@ -9,6 +9,7 @@ #include "base/values.h" #include "content/public/browser/storage_partition.h" #include "services/network/public/cpp/request_destination.h" +#include "services/network/public/cpp/url_util.h" #include "services/network/public/mojom/network_context.mojom.h" namespace content { @@ -17,13 +18,6 @@ constexpr char kType[] = "coep"; -GURL StripUsernameAndPassword(const GURL& url) { - GURL::Replacements replacements; - replacements.ClearUsername(); - replacements.ClearPassword(); - return url.ReplaceComponents(replacements); -} - } // namespace CrossOriginEmbedderPolicyReporter::CrossOriginEmbedderPolicyReporter( @@ -55,7 +49,7 @@ const GURL& blocked_url, network::mojom::RequestDestination destination, bool report_only) { - GURL url_to_pass = StripUsernameAndPassword(blocked_url); + GURL url_to_pass = network::SerializeResponseUrlForReporting(blocked_url); QueueAndNotify( {std::make_pair("type", "corp"), std::make_pair("blockedURL", url_to_pass.spec()), @@ -72,7 +66,7 @@ void CrossOriginEmbedderPolicyReporter::QueueNavigationReport( const GURL& blocked_url, bool report_only) { - GURL url_to_pass = StripUsernameAndPassword(blocked_url); + GURL url_to_pass = network::SerializeResponseUrlForReporting(blocked_url); QueueAndNotify({std::make_pair("type", "navigation"), std::make_pair("blockedURL", url_to_pass.spec())}, report_only); @@ -81,7 +75,7 @@ void CrossOriginEmbedderPolicyReporter::QueueWorkerInitializationReport( const GURL& blocked_url, bool report_only) { - GURL url_to_pass = StripUsernameAndPassword(blocked_url); + GURL url_to_pass = network::SerializeResponseUrlForReporting(blocked_url); QueueAndNotify({std::make_pair("type", "worker initialization"), std::make_pair("blockedURL", url_to_pass.spec())}, report_only); diff --git a/content/browser/network/cross_origin_embedder_policy_reporter_unittest.cc b/content/browser/network/cross_origin_embedder_policy_reporter_unittest.cc index ebe965d..8ec1a69 100644 --- a/content/browser/network/cross_origin_embedder_policy_reporter_unittest.cc +++ b/content/browser/network/cross_origin_embedder_policy_reporter_unittest.cc @@ -233,7 +233,7 @@ EXPECT_EQ(r1.group, "e1"); EXPECT_EQ(r1.url, kContextUrl); EXPECT_EQ(r1.network_anonymization_key, kNetworkIsolationKey); - EXPECT_EQ(r1.body, CreateBodyForCorp("https://www1.example.com/x#foo?bar=baz", + EXPECT_EQ(r1.body, CreateBodyForCorp("https://www1.example.com/x", RequestDestination::kScript, "enforce")); EXPECT_EQ(r2.type, "coep"); EXPECT_EQ(r2.group, "e2"); @@ -250,12 +250,14 @@ GetStoragePartition(), kContextUrl, "e1", "e2", base::UnguessableToken::Create(), net::NetworkAnonymizationKey()); - reporter.QueueCorpViolationReport(GURL("https://u:[email protected]/x"), - RequestDestination::kImage, - /*report_only=*/false); - reporter.QueueCorpViolationReport(GURL("https://u:[email protected]/y"), - RequestDestination::kScript, - /*report_only=*/true); + reporter.QueueCorpViolationReport( + GURL("https://u:[email protected]/x#fragment"), + RequestDestination::kImage, + /*report_only=*/false); + reporter.QueueCorpViolationReport( + GURL("https://u:[email protected]/y#fragment"), + RequestDestination::kScript, + /*report_only=*/true); ASSERT_EQ(2u, network_context().reports().size()); const Report& r1 = network_context().reports()[0]; @@ -375,8 +377,8 @@ EXPECT_EQ(r1.type, "coep"); EXPECT_EQ(r1.group, "e1"); EXPECT_EQ(r1.url, kContextUrl); - EXPECT_EQ(r1.body, CreateBodyForNavigation( - "https://www1.example.com/x#foo?bar=baz", "enforce")); + EXPECT_EQ(r1.body, + CreateBodyForNavigation("https://www1.example.com/x", "enforce")); EXPECT_EQ(r2.type, "coep"); EXPECT_EQ(r2.group, "e2"); EXPECT_EQ(r2.url, kContextUrl); @@ -406,8 +408,8 @@ EXPECT_EQ(r1.type, "coep"); EXPECT_EQ(r1.url, kContextUrl); EXPECT_TRUE(mojo::Equals( - r1.body, CreateMojomBodyForNavigation( - "https://www1.example.com/x#foo?bar=baz", "enforce"))); + r1.body, + CreateMojomBodyForNavigation("https://www1.example.com/x", "enforce"))); EXPECT_EQ(r2.type, "coep"); EXPECT_EQ(r2.url, kContextUrl); EXPECT_TRUE( @@ -420,10 +422,12 @@ CrossOriginEmbedderPolicyReporter reporter( GetStoragePartition(), kContextUrl, "e1", "e2", base::UnguessableToken::Create(), net::NetworkAnonymizationKey()); - reporter.QueueNavigationReport(GURL("https://u:[email protected]/x"), - /*report_only=*/false); - reporter.QueueNavigationReport(GURL("https://u:[email protected]/y"), - /*report_only=*/true); + reporter.QueueNavigationReport( + GURL("https://u:[email protected]/x#fragment"), + /*report_only=*/false); + reporter.QueueNavigationReport( + GURL("https://u:[email protected]/y#fragment"), + /*report_only=*/true); ASSERT_EQ(2u, network_context().reports().size()); const Report& r1 = network_context().reports()[0]; @@ -510,9 +514,9 @@ EXPECT_EQ(r1.type, "coep"); EXPECT_EQ(r1.url, kContextUrl); - EXPECT_TRUE(mojo::Equals( - r1.body, CreateMojomBodyForWorkerInitialization( - "https://www1.example.com/x.js#foo?bar=baz", "enforce"))); + EXPECT_TRUE( + mojo::Equals(r1.body, CreateMojomBodyForWorkerInitialization( + "https://www1.example.com/x.js", "enforce"))); EXPECT_EQ(r2.type, "coep"); EXPECT_EQ(r2.url, kContextUrl); EXPECT_TRUE(mojo::Equals( @@ -527,10 +531,10 @@ GetStoragePartition(), kContextUrl, "e1", "e2", base::UnguessableToken::Create(), net::NetworkAnonymizationKey()); reporter.QueueWorkerInitializationReport( - GURL("https://u:[email protected]/x.js"), + GURL("https://u:[email protected]/x.js#fragment"), /*report_only=*/false); reporter.QueueWorkerInitializationReport(
Regression Test / PoC
diff --git a/content/browser/network/cross_origin_embedder_policy_reporter_unittest.cc b/content/browser/network/cross_origin_embedder_policy_reporter_unittest.cc
index ebe965d..8ec1a69 100644
--- a/content/browser/network/cross_origin_embedder_policy_reporter_unittest.cc
+++ b/content/browser/network/cross_origin_embedder_policy_reporter_unittest.cc
@@ -233,7 +233,7 @@
EXPECT_EQ(r1.group, "e1");
EXPECT_EQ(r1.url, kContextUrl);
EXPECT_EQ(r1.network_anonymization_key, kNetworkIsolationKey);
- EXPECT_EQ(r1.body, CreateBodyForCorp("https://www1.example.com/x#foo?bar=baz",
+ EXPECT_EQ(r1.body, CreateBodyForCorp("https://www1.example.com/x",
RequestDestination::kScript, "enforce"));
EXPECT_EQ(r2.type, "coep");
EXPECT_EQ(r2.group, "e2");
@@ -250,12 +250,14 @@
GetStoragePartition(), kContextUrl, "e1", "e2",
base::UnguessableToken::Create(), net::NetworkAnonymizationKey());
- reporter.QueueCorpViolationReport(GURL("https://u:[email protected]/x"),
- RequestDestination::kImage,
- /*report_only=*/false);
- reporter.QueueCorpViolationReport(GURL("https://u:[email protected]/y"),
- RequestDestination::kScript,
- /*report_only=*/true);
+ reporter.QueueCorpViolationReport(
+ GURL("https://u:[email protected]/x#fragment"),
+ RequestDestination::kImage,
+ /*report_only=*/false);
+ reporter.QueueCorpViolationReport(
+ GURL("https://u:[email protected]/y#fragment"),
+ RequestDestination::kScript,
+ /*report_only=*/true);
ASSERT_EQ(2u, network_context().reports().size());
const Report& r1 = network_context().reports()[0];
@@ -375,8 +377,8 @@
EXPECT_EQ(r1.type, "coep");
EXPECT_EQ(r1.group, "e1");
EXPECT_EQ(r1.url, kContextUrl);
- EXPECT_EQ(r1.body, CreateBodyForNavigation(
- "https://www1.example.com/x#foo?bar=baz", "enforce"));
+ EXPECT_EQ(r1.body,
+ CreateBodyForNavigation("https://www1.example.com/x", "enforce"));
EXPECT_EQ(r2.type, "coep");
EXPECT_EQ(r2.group, "e2");
EXPECT_EQ(r2.url, kContextUrl);
@@ -406,8 +408,8 @@
EXPECT_EQ(r1.type, "coep");
EXPECT_EQ(r1.url, kContextUrl);
EXPECT_TRUE(mojo::Equals(
- r1.body, CreateMojomBodyForNavigation(
- "https://www1.example.com/x#foo?bar=baz", "enforce")));
+ r1.body,
+ CreateMojomBodyForNavigation("https://www1.example.com/x", "enforce")));
EXPECT_EQ(r2.type, "coep");
EXPECT_EQ(r2.url, kContextUrl);
EXPECT_TRUE(
@@ -420,10 +422,12 @@
CrossOriginEmbedderPolicyReporter reporter(
GetStoragePartition(), kContextUrl, "e1", "e2",
base::UnguessableToken::Create(), net::NetworkAnonymizationKey());
- reporter.QueueNavigationReport(GURL("https://u:[email protected]/x"),
- /*report_only=*/false);
- reporter.QueueNavigationReport(GURL("https://u:[email protected]/y"),
- /*report_only=*/true);
+ reporter.QueueNavigationReport(
+ GURL("https://u:[email protected]/x#fragment"),
+ /*report_only=*/false);
+ reporter.QueueNavigationReport(
+ GURL("https://u:[email protected]/y#fragment"),
+ /*report_only=*/true);
ASSERT_EQ(2u, network_context().reports().size());
const Report& r1 = network_context().reports()[0];
@@ -510,9 +514,9 @@
EXPECT_EQ(r1.type, "coep");
EXPECT_EQ(r1.url, kContextUrl);
- EXPECT_TRUE(mojo::Equals(
- r1.body, CreateMojomBodyForWorkerInitialization(
- "https://www1.example.com/x.js#foo?bar=baz", "enforce")));
+ EXPECT_TRUE(
+ mojo::Equals(r1.body, CreateMojomBodyForWorkerInitialization(
+ "https://www1.example.com/x.js", "enforce")));
EXPECT_EQ(r2.type, "coep");
EXPECT_EQ(r2.url, kContextUrl);
EXPECT_TRUE(mojo::Equals(
@@ -527,10 +531,10 @@
GetStoragePartition(), kContextUrl, "e1", "e2",
base::UnguessableToken::Create(), net::NetworkAnonymizationKey());
reporter.QueueWorkerInitializationReport(
- GURL("https://u:[email protected]/x.js"),
+ GURL("https://u:[email protected]/x.js#fragment"),
/*report_only=*/false);
reporter.QueueWorkerInitializationReport(
- GURL("https://u:[email protected]/y.js"),
+ GURL("https://u:[email protected]/y.js#fragment"),
/*report_only=*/true);
ASSERT_EQ(2u, network_context().reports().size());
diff --git a/content/browser/security/dip/document_isolation_policy_reporter_unittest.cc b/content/browser/security/dip/document_isolation_policy_reporter_unittest.cc
index 6376f05..ae9086a 100644
--- a/content/browser/security/dip/document_isolation_policy_reporter_unittest.cc
+++ b/content/browser/security/dip/document_isolation_policy_reporter_unittest.cc
@@ -178,7 +178,7 @@
EXPECT_EQ(r1.group, "e1");
EXPECT_EQ(r1.url, kContextUrl);
EXPECT_EQ(r1.network_anonymization_key, kNetworkIsolationKey);
- EXPECT_EQ(r1.body, CreateBodyForCorp("https://www1.example.com/x#foo?bar=baz",
+ EXPECT_EQ(r1.body, CreateBodyForCorp("https://www1.example.com/x",
RequestDestination::kScript, "enforce"));
EXPECT_EQ(r2.type, "dip");
EXPECT_EQ(r2.group, "e2");
@@ -195,12 +195,14 @@
GetStoragePartition(), kContextUrl, "e1", "e2",
base::UnguessableToken::Create(), net::NetworkAnonymizationKey());
- reporter.QueueCorpViolationReport(GURL("https://u:[email protected]/x"),
- RequestDestination::kImage,
- /*report_only=*/false);
- reporter.QueueCorpViolationReport(GURL("https://u:[email protected]/y"),
- RequestDestination::kScript,
- /*report_only=*/true);
+ reporter.QueueCorpViolationReport(
+ GURL("https://u:[email protected]/x#fragment"),
+ RequestDestination::kImage,
+ /*report_only=*/false);
+ reporter.QueueCorpViolationReport(
+ GURL("https://u:[email protected]/y#fragment"),
+ RequestDestination::kScript,
+ /*report_only=*/true);
ASSERT_EQ(2u, network_context().reports().size());
const Report& r1 = network_context().reports()[0];
diff --git a/services/network/public/cpp/url_util_unittest.cc b/services/network/public/cpp/url_util_unittest.cc
new file mode 100644
index 0000000..6514e0c
--- /dev/null
+++ b/services/network/public/cpp/url_util_unittest.cc
@@ -0,0 +1,41 @@
+// 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 "services/network/public/cpp/url_util.h"
+
+#include "testing/gtest/include/gtest/gtest.h"
+#include "url/gurl.h"
+
+namespace network {
+
+TEST(URLUtilTest, SerializeResponseUrlForReporting) {
+ struct {
+ const char* input;
+ const char* expected;
+ } kTestCases[] = {
+ {"https://example.com/", "https://example.com/"},
+ {"https://example.com/#fragment", "https://example.com/"},
+ {"https://user:[email protected]/", "https://example.com/"},
+ {"https://user:[email protected]/#fragment", "https://example.com/"},
+ {"https://example.com/?query", "https://example.com/?query"},
+ {"https://example.com/?query#fragment", "https://example.com/?query"},
+ {"http://example.com/", "http://example.com/"},
+ {"http://example.com/#fragment", "http://example.com/"},
+ {"http://user:[email protected]/", "http://example.com/"},
+ {"http://user:[email protected]/#fragment", "http://example.com/"},
+ {"data:text/html,<html></html>", "data:text/html,<html></html>"},
+ {"data:text/html,<html></html>#fragment", "data:text/html,<html></html>"},
+ {"blob:https://example.com/uuid", "blob:https://example.com/uuid"},
+ {"blob:https://example.com/uuid#fragment",
+ "blob:https://example.com/uuid"},
+ };
+
+ for (const auto& test_case : kTestCases) {
+ EXPECT_EQ(SerializeResponseUrlForReporting(GURL(test_case.input)).spec(),
+ test_case.expected)
+ << "For input: " << test_case.input;
+ }
+}
+
+} // namespace network
diff --git a/third_party/blink/web_tests/external/wpt/html/cross-origin-embedder-policy/reporting-subresource-corp.https.html b/third_party/blink/web_tests/external/wpt/html/cross-origin-embedder-policy/reporting-subresource-corp.https.html
index e56124a..da73f83 100644
--- a/third_party/blink/web_tests/external/wpt/html/cross-origin-embedder-policy/reporting-subresource-corp.https.html
+++ b/third_party/blink/web_tests/external/wpt/html/cross-origin-embedder-policy/reporting-subresource-corp.https.html
@@ -162,6 +162,15 @@
check: (reports, contextUrl, url) => {
checkReport(reports[0], contextUrl, url, 'reporting', '');
checkReport(reports[1], contextUrl, url, 'enforce', '');
+ },
+}, {
+ name: 'blocked due to COEP (with fragment)',
+ url: `${REMOTE_ORIGIN}/common/text-plain.txt#fragment`,
+ expected_count: 2,
+ check: (reports, contextUrl, url) => {
+ const strippedUrl = url.split('#')[0];
+ checkReport(reports[0], contextUrl, strippedUrl, 'reporting', '');
+ checkReport(reports[1], contextUrl, strippedUrl, 'enforce', '');
}
}, {
name: 'blocked during redirect',
diff --git a/third_party/blink/web_tests/external/wpt/html/document-isolation-policy/reporting-subresource-corp.tentative.https.html b/third_party/blink/web_tests/external/wpt/html/document-isolation-policy/reporting-subresource-corp.tentative.https.html
index 013eb34..5ced0aa 100644
--- a/third_party/blink/web_tests/external/wpt/html/document-isolation-policy/reporting-subresource-corp.tentative.https.html
+++ b/third_party/blink/web_tests/external/wpt/html/document-isolation-policy/reporting-subresource-corp.tentative.https.html
@@ -133,6 +133,16 @@
}
},
{
+ name: 'blocked due to DIP (with fragment)',
+ url: `${REMOTE_ORIGIN}/common/square.png#fragment`,
+ expected_count: 2,
+ check: (reports, contextUrl, url) => {
+ const strippedUrl = url.split('#')[0];
+ checkReport(reports[0], contextUrl, strippedUrl, 'reporting', '');
+ checkReport(reports[1], contextUrl, strippedUrl, 'enforce', '');
+ }
+ },
+ {
name: 'blocked during redirect',
url: `${ORIGIN}/common/redirect.py?location=` +
encodeURIComponent(`${REMOTE_ORIGIN}/common/square.png`),
Original Bug Report
Potential information leak of URL fragments in COEP and DIP violation reports
Flapjack, 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 without the Chrome Security team. Please see go/chrome-ai-generated-security-bugs-faq for more information.
Overview: The CrossOriginEmbedderPolicyReporter and DocumentIsolationPolicyReporter fail to strip URL fragments when generating violation reports. This violates the Fetch/HTML specifications and can potentially leak sensitive data, such as OAuth tokens, embedded in the fragment of a blocked URL to an attacker’s reporting endpoint.
Affected files:
content/browser/network/cross_origin_embedder_policy_reporter.cccontent/browser/security/dip/document_isolation_policy_reporter.cc
Estimated timestamp from git blame: 2025-01-16
Vulnerability Details
The CrossOriginEmbedderPolicyReporter (in content/browser/network/cross_origin_embedder_policy_reporter.cc) and the DocumentIsolationPolicyReporter (in content/browser/security/dip/document_isolation_policy_reporter.cc) are responsible for generating violation reports when documents or subresources violate Cross-Origin Embedder Policy (COEP) or Document Isolation Policy (DIP).
When a violation occurs, these classes include the blockedURL in the report. To sanitize the URL, they rely on a helper function called StripUsernameAndPassword:
// content/browser/network/cross_origin_embedder_policy_reporter.cc
GURL StripUsernameAndPassword(const GURL& url) {
GURL::Replacements replacements;
replacements.ClearUsername();
replacements.ClearPassword();
return url.ReplaceComponents(replacements);
}
This implementation clears the username and password but explicitly fails to clear the URL fragment (the portion after the #). According to the HTML specification for queuing a COEP violation report, the blockedURL must be the result of “stripping url for use as a referrer”, which mandates setting the URL’s fragment to null. By failing to strip the fragment, Chromium inadvertently exposes it in the generated report.
Potential Security Impact
This flaw allows sensitive information contained in fragments, such as OAuth access_tokens or internal session identifiers, to be leaked cross-origin.
Suggested Attack Scenario (Client-Side Navigation): Note: These steps represent a potential attack path derived from source code analysis.
- An attacker hosts a malicious site and serves the header
Cross-Origin-Embedder-Policy-Report-Only: require-corpalong with a valid Reporting API endpoint. - The attacker registers a
ReportingObservervia JavaScript for"coep"reports. - The attacker embeds a vulnerable, frameable cross-origin victim application (
<iframe src="https://victim.com/auth">). - The victim application successfully authenticates the user and executes a client-side navigation to pass a sensitive token via the fragment:
window.location.assign("https://victim.com/callback#access_token=SECRET");. - The browser intercepts the navigation. Because it is a cross-document navigation,
NavigationRequest::CheckResponseAdherenceToCoepevaluates the new child document against the attacker’srequire-corppolicy. - Because the victim’s callback lacks a CORP policy, the browser queues a COEP navigation violation report.
- The URL passed to the reporter is
redirect_chain_[0], which is the exact client-side URL including the fragment. - The flawed
StripUsernameAndPasswordfunction preserves#access_token=SECRET. - The browser sends the report containing the sensitive fragment to the attacker’s JavaScript
ReportingObserverand to their server-side reporting endpoint.
Furthermore, this same flaw applies to subresource requests blocked by CORP. A victim iframe performing fetch("https://api.victim.com/data#secret") will have the fragment preserved by Blink over Mojo IPC, and the resulting COEP/DIP report will leak the fragment to the embedder.
Recommended Fix
The StripUsernameAndPassword function in both reporters should be updated to strip fragments. This can be achieved by utilizing GURL::GetAsReferrer() (which correctly strips username, password, and fragment) instead of a custom replacement logic. For example, Chrome’s COOP reporter (CrossOriginOpenerPolicyReporter) already correctly implements this via its SanitizedURL() helper.
Evaluated with Chrome root at commit: c0eb5541aebfa4ea08806eaf6e94bcc69f87ab2f
Results 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.