Medium chrome Cross Origin 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in CORS
DescriptionInappropriate implementation in CORS
ComponentCORS
Bug ClassCross Origin
Tracker496231853
Fix commitace38859fcb3 (chromium/src) +151/-4
CISA KEVNot listed
CreditedGoogle
Disclosed2026-05-12

Changed Functions

FunctionChangeNotes
if
services/network/cors/cors_url_loader.cc
modified
for
services/network/cors/cors_url_loader_shared_dictionary_unittest.cc
modified
TEST_F
services/network/cors/cors_url_loader_shared_dictionary_unittest.cc
modified
CorsURLLoaderTAOTest
services/network/cors/cors_url_loader_tao_unittest.cc
modified

Files Changed

  • services/network/cors/cors_url_loader.cc
  • services/network/cors/cors_url_loader_shared_dictionary_unittest.cc
  • services/network/cors/cors_url_loader_tao_unittest.cc
  • services/network/shared_dictionary/shared_dictionary_storage.h
From ace38859fcb3f8fe1e3c1923382a50ef2743f72f Mon Sep 17 00:00:00 2001
From: Patrick Meenan <pmeenan@chromium.org>
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 <pmeenan@chromium.org>
Reviewed-by: Tsuyoshi Horo <horo@chromium.org>
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
Loading diff…

Regression Test / PoC

shipped with the fix
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);
   }
 };
Loading diff…

Original Bug Report

reported by vi...@google.com

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.cc
  • services/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.

  1. An attacker-controlled page at https://attacker.example triggers a top-level navigation to https://attacker.example/redirect.
  2. The server responds with a 302 redirect to https://victim.example/page, an authenticated endpoint containing sensitive data.
  3. The browser reuses the CorsURLLoader, which retains the isolation context (isolation_info_ and shared_dictionary_storage_) of attacker.example.
  4. https://victim.example/page responds with its authenticated content and a Use-As-Dictionary header. The dictionary is incorrectly stored in the attacker.example partition because of the stale storage handle.
  5. Later, the attacker’s page performs a CORS fetch() to https://victim.example in no-cors mode.
  6. The SharedDictionaryNetworkTransaction derives the isolation key for attacker.example, finds the misplaced dictionary, and includes the Available-Dictionary header (containing the SHA-256 of the victim’s authenticated body) in the request.
  7. 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.

View on issue tracker