CVE-2026-8576
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifservices/network/cors/cors_url_loader.cc |
modified | |
forservices/network/cors/cors_url_loader_shared_dictionary_unittest.cc |
modified | |
TEST_Fservices/network/cors/cors_url_loader_shared_dictionary_unittest.cc |
modified | |
CorsURLLoaderTAOTestservices/network/cors/cors_url_loader_tao_unittest.cc |
modified |
Files Changed
services/network/cors/cors_url_loader.ccservices/network/cors/cors_url_loader_shared_dictionary_unittest.ccservices/network/cors/cors_url_loader_tao_unittest.ccservices/network/shared_dictionary/shared_dictionary_storage.h
Patch
From ace38859fcb3f8fe1e3c1923382a50ef2743f72f Mon Sep 17 00:00:00 2001 From: Patrick Meenan <[email protected]> Date: Fri, 27 Mar 2026 07:00:08 -0700 Subject: [PATCH] Update the dictionary storage partition on redirect This makes sure that the compression dictionary storage in CorsUrlLoaded is updated on redirects if the redirect causes the isolation key to change. This is necessary to make sure any dictionaries stored as a result of a "Use-As-Dictionary" response header are written to the correct location. Bug: 496231853 Change-Id: Ide7d26ca142c99f0414e0335f2a3765fe0d95111 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7704053 Commit-Queue: Patrick Meenan <[email protected]> Reviewed-by: Tsuyoshi Horo <[email protected]> Cr-Commit-Position: refs/heads/main@{#1606185} --- diff --git a/services/network/cors/cors_url_loader.cc b/services/network/cors/cors_url_loader.cc index 17b6b9a..7607ce4 100644 --- a/services/network/cors/cors_url_loader.cc +++ b/services/network/cors/cors_url_loader.cc @@ -493,6 +493,33 @@ const std::string original_method = std::move(request_.method); request_.UpdateOnRedirect(redirect_info_); + // Update the shared dictionary storage location if the isolation key changed + // as a result of the redirect for a navigation. + if (request_.mode == mojom::RequestMode::kNavigate) { + CHECK(request_.trusted_params); + isolation_info_ = request_.trusted_params->isolation_info; + if (shared_dictionary_storage_) { + // `client_security_state` is not set for top-level navigation requests. + const bool secure_context = + request_.trusted_params->client_security_state + ? request_.trusted_params->client_security_state + ->is_web_secure_context + : network::IsUrlPotentiallyTrustworthy(request_.url); + const auto shared_dictionary_isolation_key = + (secure_context && context_->GetSharedDictionaryManager()) + ? net::SharedDictionaryIsolationKey::MaybeCreate(isolation_info_) + : std::nullopt; + if (!shared_dictionary_isolation_key) { + shared_dictionary_storage_.reset(); + } else if (shared_dictionary_storage_->isolation_key() != + *shared_dictionary_isolation_key) { + shared_dictionary_storage_ = + context_->GetSharedDictionaryManager()->GetStorage( + *shared_dictionary_isolation_key); + } + } + } + // The request method can be changed to "GET". In this case we need to // reset the request body manually. if (request_.method == net::HttpRequestHeaders::kGetMethod) diff --git a/services/network/cors/cors_url_loader_shared_dictionary_unittest.cc b/services/network/cors/cors_url_loader_shared_dictionary_unittest.cc index ed0e1461..61fb57f 100644 --- a/services/network/cors/cors_url_loader_shared_dictionary_unittest.cc +++ b/services/network/cors/cors_url_loader_shared_dictionary_unittest.cc @@ -164,6 +164,15 @@ ->GetStorageCountForTesting(); } + size_t GetDictionaryCount(SharedDictionaryStorage* storage) { + const auto& dictionary_map = GetInMemoryDictionaryMap(storage); + size_t count = 0; + for (const auto& it : dictionary_map) { + count += it.second.size(); + } + return count; + } + net::IsolationInfo isolation_info_; mojo::ScopedDataPipeProducerHandle producer_handle_; mojo::ScopedDataPipeConsumerHandle consumer_handle_; @@ -574,4 +583,85 @@ EXPECT_EQ(0u, GetStorageCount()); } +TEST_F(CorsURLLoaderSharedDictionaryTest, CrossOriginRedirect) { + ResetFactoryParams factory_params; + factory_params.is_trusted = true; + CorsURLLoaderTestBase::ResetFactory(isolation_info_.frame_origin(), + OriginatingProcessId::browser(), + factory_params); + + const GURL kUrlA("https://a.test/test"); + const GURL kUrlB("https://b.test/test"); + + ResourceRequest request; + request.method = "GET"; + request.mode = mojom::RequestMode::kNavigate; + request.url = kUrlA; + request.request_initiator = url::Origin::Create(kUrlA); + request.site_for_cookies = + net::SiteForCookies::FromOrigin(url::Origin::Create(kUrlA)); + request.shared_dictionary_writer_enabled = true; + + request.trusted_params = ResourceRequest::TrustedParams(); + url::Origin originA = url::Origin::Create(kUrlA); + request.trusted_params->isolation_info = net::IsolationInfo::Create( + net::IsolationInfo::RequestType::kMainFrame, originA, originA, + net::SiteForCookies::FromOrigin(originA)); + request.trusted_params->client_security_state = + ClientSecurityStateBuilder().WithIsSecureContext(true).Build(); + + CreateLoaderAndStart(request); + RunUntilCreateLoaderAndStartCalled(); + + // Make sure a single SharedDictionaryStorage was created for the initial + // request and that no actual dictionaries were written. + EXPECT_EQ(1u, GetStorageCount()); + std::optional<net::SharedDictionaryIsolationKey> isolation_key_a = + net::SharedDictionaryIsolationKey::MaybeCreate( + request.trusted_params->isolation_info); + ASSERT_TRUE(isolation_key_a); + scoped_refptr<SharedDictionaryStorage> storage_a = + network_context()->GetSharedDictionaryManager()->GetStorage( + *isolation_key_a); + EXPECT_EQ(0u, GetDictionaryCount(storage_a.get())); + + // Follow the redirect to a different origin. + net::RedirectInfo redirect_info; + redirect_info.new_url = kUrlB; + redirect_info.new_method = "GET"; + redirect_info.new_site_for_cookies = + net::SiteForCookies::FromOrigin(url::Origin::Create(kUrlB)); + redirect_info.new_referrer_policy = net::ReferrerPolicy::NO_REFERRER; + + NotifyLoaderClientOnReceiveRedirect(redirect_info); + + FollowRedirect(); + CreateDataPipeAndWriteTestData(); + CallOnReceiveResponseAndOnCompleteAndFinishBody(); + + RunUntilComplete(); + EXPECT_EQ(net::OK, client().completion_status().error_code); + + // Make sure a second SharedDictionaryStorage was created for the final + // request and that a dictionary was written to the correctly partitioned + // storage and there is still nothing in the first storage. + EXPECT_EQ(2u, GetStorageCount()); + + EXPECT_EQ(0u, GetDictionaryCount(storage_a.get())); + CheckDictionaryInStorage(/*expect_exists=*/false); + + url::Origin originB = url::Origin::Create(kUrlB); + isolation_info_ = net::IsolationInfo::Create( + net::IsolationInfo::RequestType::kMainFrame, originB, originB, + net::SiteForCookies::FromOrigin(originB)); + std::optional<net::SharedDictionaryIsolationKey> isolation_key_b = + net::SharedDictionaryIsolationKey::MaybeCreate(isolation_info_); + ASSERT_TRUE(isolation_key_b); + scoped_refptr<SharedDictionaryStorage> storage_b = + network_context()->GetSharedDictionaryManager()->GetStorage( + *isolation_key_b); + EXPECT_EQ(1u, GetDictionaryCount(storage_b.get())); + CheckDictionaryInStorage(/*expect_exists=*/true, kUrlB); +} + } // namespace network::cors diff --git a/services/network/cors/cors_url_loader_tao_unittest.cc b/services/network/cors/cors_url_loader_tao_unittest.cc index be3b3dd..33d8ec0 100644 --- a/services/network/cors/cors_url_loader_tao_unittest.cc +++ b/services/network/cors/cors_url_loader_tao_unittest.cc @@ -34,10 +34,22 @@ class CorsURLLoaderTAOTest : public CorsURLLoaderTestBase { protected: void CreateLoaderAndStartNavigation(const GURL& origin, const GURL& url) { - ResetFactory(std::nullopt /* initiator */, OriginatingProcessId::browser()); - CreateLoaderAndStart(origin, url, mojom::RequestMode::kNavigate, - mojom::RedirectMode::kManual, - mojom::CredentialsMode::kInclude); + ResetFactoryParams params; + params.is_trusted = true; + ResetFactory(std::nullopt /* initiator */, OriginatingProcessId::browser(), + params); + + ResourceRequest request; + request.mode = mojom::RequestMode::kNavigate; + request.redirect_mode = mojom::RedirectMode::kManual; + request.credentials_mode = mojom::CredentialsMode::kInclude; + request.method = net::HttpRequestHeaders::kGetMethod; + request.url = url; + request.navigation_redirect_chain.push_back(url); + request.request_initiator = url::Origin::Create(origin); + request.devtools_request_id = "devtools"; + request.trusted_params = ResourceRequest::TrustedParams(); + CreateLoaderAndStart(request); } }; diff --git a/services/network/shared_dictionary/shared_dictionary_storage.h b/services/network/shared_dictionary/shared_dictionary_storage.h index 4b1797e..bd8f7e9 100644 --- a/services/network/shared_dictionary/shared_dictionary_storage.h +++ b/services/network/shared_dictionary/shared_dictionary_storage.h
Regression Test / PoC
diff --git a/services/network/cors/cors_url_loader_shared_dictionary_unittest.cc b/services/network/cors/cors_url_loader_shared_dictionary_unittest.cc
index ed0e1461..61fb57f 100644
--- a/services/network/cors/cors_url_loader_shared_dictionary_unittest.cc
+++ b/services/network/cors/cors_url_loader_shared_dictionary_unittest.cc
@@ -164,6 +164,15 @@
->GetStorageCountForTesting();
}
+ size_t GetDictionaryCount(SharedDictionaryStorage* storage) {
+ const auto& dictionary_map = GetInMemoryDictionaryMap(storage);
+ size_t count = 0;
+ for (const auto& it : dictionary_map) {
+ count += it.second.size();
+ }
+ return count;
+ }
+
net::IsolationInfo isolation_info_;
mojo::ScopedDataPipeProducerHandle producer_handle_;
mojo::ScopedDataPipeConsumerHandle consumer_handle_;
@@ -574,4 +583,85 @@
EXPECT_EQ(0u, GetStorageCount());
}
+TEST_F(CorsURLLoaderSharedDictionaryTest, CrossOriginRedirect) {
+ ResetFactoryParams factory_params;
+ factory_params.is_trusted = true;
+ CorsURLLoaderTestBase::ResetFactory(isolation_info_.frame_origin(),
+ OriginatingProcessId::browser(),
+ factory_params);
+
+ const GURL kUrlA("https://a.test/test");
+ const GURL kUrlB("https://b.test/test");
+
+ ResourceRequest request;
+ request.method = "GET";
+ request.mode = mojom::RequestMode::kNavigate;
+ request.url = kUrlA;
+ request.request_initiator = url::Origin::Create(kUrlA);
+ request.site_for_cookies =
+ net::SiteForCookies::FromOrigin(url::Origin::Create(kUrlA));
+ request.shared_dictionary_writer_enabled = true;
+
+ request.trusted_params = ResourceRequest::TrustedParams();
+ url::Origin originA = url::Origin::Create(kUrlA);
+ request.trusted_params->isolation_info = net::IsolationInfo::Create(
+ net::IsolationInfo::RequestType::kMainFrame, originA, originA,
+ net::SiteForCookies::FromOrigin(originA));
+ request.trusted_params->client_security_state =
+ ClientSecurityStateBuilder().WithIsSecureContext(true).Build();
+
+ CreateLoaderAndStart(request);
+ RunUntilCreateLoaderAndStartCalled();
+
+ // Make sure a single SharedDictionaryStorage was created for the initial
+ // request and that no actual dictionaries were written.
+ EXPECT_EQ(1u, GetStorageCount());
+ std::optional<net::SharedDictionaryIsolationKey> isolation_key_a =
+ net::SharedDictionaryIsolationKey::MaybeCreate(
+ request.trusted_params->isolation_info);
+ ASSERT_TRUE(isolation_key_a);
+ scoped_refptr<SharedDictionaryStorage> storage_a =
+ network_context()->GetSharedDictionaryManager()->GetStorage(
+ *isolation_key_a);
+ EXPECT_EQ(0u, GetDictionaryCount(storage_a.get()));
+
+ // Follow the redirect to a different origin.
+ net::RedirectInfo redirect_info;
+ redirect_info.new_url = kUrlB;
+ redirect_info.new_method = "GET";
+ redirect_info.new_site_for_cookies =
+ net::SiteForCookies::FromOrigin(url::Origin::Create(kUrlB));
+ redirect_info.new_referrer_policy = net::ReferrerPolicy::NO_REFERRER;
+
+ NotifyLoaderClientOnReceiveRedirect(redirect_info);
+
+ FollowRedirect();
+ CreateDataPipeAndWriteTestData();
+ CallOnReceiveResponseAndOnCompleteAndFinishBody();
+
+ RunUntilComplete();
+ EXPECT_EQ(net::OK, client().completion_status().error_code);
+
+ // Make sure a second SharedDictionaryStorage was created for the final
+ // request and that a dictionary was written to the correctly partitioned
+ // storage and there is still nothing in the first storage.
+ EXPECT_EQ(2u, GetStorageCount());
+
+ EXPECT_EQ(0u, GetDictionaryCount(storage_a.get()));
+ CheckDictionaryInStorage(/*expect_exists=*/false);
+
+ url::Origin originB = url::Origin::Create(kUrlB);
+ isolation_info_ = net::IsolationInfo::Create(
+ net::IsolationInfo::RequestType::kMainFrame, originB, originB,
+ net::SiteForCookies::FromOrigin(originB));
+ std::optional<net::SharedDictionaryIsolationKey> isolation_key_b =
+ net::SharedDictionaryIsolationKey::MaybeCreate(isolation_info_);
+ ASSERT_TRUE(isolation_key_b);
+ scoped_refptr<SharedDictionaryStorage> storage_b =
+ network_context()->GetSharedDictionaryManager()->GetStorage(
+ *isolation_key_b);
+ EXPECT_EQ(1u, GetDictionaryCount(storage_b.get()));
+ CheckDictionaryInStorage(/*expect_exists=*/true, kUrlB);
+}
+
} // namespace network::cors
diff --git a/services/network/cors/cors_url_loader_tao_unittest.cc b/services/network/cors/cors_url_loader_tao_unittest.cc
index be3b3dd..33d8ec0 100644
--- a/services/network/cors/cors_url_loader_tao_unittest.cc
+++ b/services/network/cors/cors_url_loader_tao_unittest.cc
@@ -34,10 +34,22 @@
class CorsURLLoaderTAOTest : public CorsURLLoaderTestBase {
protected:
void CreateLoaderAndStartNavigation(const GURL& origin, const GURL& url) {
- ResetFactory(std::nullopt /* initiator */, OriginatingProcessId::browser());
- CreateLoaderAndStart(origin, url, mojom::RequestMode::kNavigate,
- mojom::RedirectMode::kManual,
- mojom::CredentialsMode::kInclude);
+ ResetFactoryParams params;
+ params.is_trusted = true;
+ ResetFactory(std::nullopt /* initiator */, OriginatingProcessId::browser(),
+ params);
+
+ ResourceRequest request;
+ request.mode = mojom::RequestMode::kNavigate;
+ request.redirect_mode = mojom::RedirectMode::kManual;
+ request.credentials_mode = mojom::CredentialsMode::kInclude;
+ request.method = net::HttpRequestHeaders::kGetMethod;
+ request.url = url;
+ request.navigation_redirect_chain.push_back(url);
+ request.request_initiator = url::Origin::Create(origin);
+ request.devtools_request_id = "devtools";
+ request.trusted_params = ResourceRequest::TrustedParams();
+ CreateLoaderAndStart(request);
}
};
Original Bug Report
Potential SharedDictionary partition bypass via stale isolation key on navigation redirect
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: A potential vulnerability in CorsURLLoader allows a cross-origin navigation redirect to bypass SharedDictionary storage partitioning. Due to stale isolation information being used after a redirect, an authenticated dictionary from a victim site can be stored in an attacker’s partition, enabling a compression-oracle side-channel attack.
Affected files:
services/network/cors/cors_url_loader.ccservices/network/cors/cors_url_loader_factory.cc
Estimated timestamp from git blame: 2026-02-03
Summary
A potential vulnerability in CorsURLLoader allows a cross-site information leak by bypassing SharedDictionary storage partitioning. When a main-frame navigation is redirected (e.g., from an attacker-controlled site to a victim site), the CorsURLLoader is reused but fails to update its isolation information. This results in the victim’s authenticated dictionary being stored within the attacker’s storage partition.
Technical Details
In services/network/cors/cors_url_loader.cc, the shared_dictionary_storage_ and isolation_info_ members are initialized during construction. For main-frame navigations that involve a redirect (e.g., A.com → 302 → B.com), NavigationURLLoaderImpl reuses the existing CorsURLLoader instance by calling its FollowRedirect method.
While the underlying URLRequest updates its isolation_info correctly during the redirect (via ResourceRequest::UpdateOnRedirect), CorsURLLoader does not update its own isolation_info_ or its shared_dictionary_storage_ handle in FollowRedirect().
If the redirect destination (the victim site, B.com) provides a Use-As-Dictionary header in its response, CorsURLLoader::MaybeCreateWriter (around lines 582-593) uses the stale storage handle. This causes the victim’s authenticated response body to be written into the dictionary storage keyed by the attacker’s isolation information (e.g., top-frame and frame origin set to A.com).
Potential Attack Scenario
Note: These steps describe a potential attack path; our setup has not executed a working proof of concept.
- An attacker-controlled page at
https://attacker.exampletriggers a top-level navigation tohttps://attacker.example/redirect. - The server responds with a 302 redirect to
https://victim.example/page, an authenticated endpoint containing sensitive data. - The browser reuses the
CorsURLLoader, which retains the isolation context (isolation_info_andshared_dictionary_storage_) ofattacker.example. https://victim.example/pageresponds with its authenticated content and aUse-As-Dictionaryheader. The dictionary is incorrectly stored in theattacker.examplepartition because of the stale storage handle.- Later, the attacker’s page performs a CORS
fetch()tohttps://victim.exampleinno-corsmode. - The
SharedDictionaryNetworkTransactionderives the isolation key forattacker.example, finds the misplaced dictionary, and includes theAvailable-Dictionaryheader (containing the SHA-256 of the victim’s authenticated body) in the request. - If the victim site supports dictionary-based compression (dcb/dcz) and has an endpoint that reflects user input, the attacker can use a compression-ratio side channel (similar to a BREACH attack) to probe and extract the content of the victim’s authenticated body stored in the dictionary.
Additionally, the SharedDictionaryAccessChecker uses the stale isolation_info_ for cookie-setting checks, which may lead to further security policy bypasses.
Impact
This issue results in a storage partition bypass and a cross-origin information leak. An attacker can potentially extract sensitive, authenticated data from a victim site that utilizes Shared Dictionaries and dictionary-compressed responses.
Suggested Fix
In CorsURLLoader::FollowRedirect, the isolation_info_ member should be updated to match the new origin after a redirect (similar to how request_.trusted_params->isolation_info is updated). Additionally, a new SharedDictionaryStorage handle should be fetched from the SharedDictionaryManager using the updated isolation key, replacing the stale shared_dictionary_storage_ member variable.
Evaluated with Chrome root at commit: bb48272cafb7e24c93f55ef40da398cd206ee651
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. Please feel free to reach out to me if you have concerns or feedback.