Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactObservable discrepancy in Network
DescriptionObservable discrepancy in Network
ComponentNetwork
Bug ClassLogic Error
Tracker513003268
Fix commit7fa16aedef2f (chromium/src) +398/-66
CISA KEVNot listed
CreditedGoogle
Disclosed2026-09-08

Background

`NoVarySearchCache`
A net:: HTTP-cache helper that maps a request URL (ignoring query parameters marked non-varying via No-Vary-Search) to a previously cached response entry.
Cache partition key
The top-level-site isolation key that scopes cache state so that resources loaded under one site cannot be trivially shared with or observed by another site.
LRU eviction
A least-recently-used policy where inserting a new entry over a size limit evicts the entry that was accessed longest ago.
XS-Leak (cross-site leak)
An attack in which a malicious site infers information about a victim’s activity on another site by observing an indirect, shared side effect such as a cache hit/miss or an eviction.

Root Cause Analysis

NoVarySearchCache stored its lookup entries in a partitions_ map keyed by the cache partition key, correctly isolating which entries each site could look up, but it drove eviction from a single process-global LRU list (lru_) shared across every partition. When one partition inserted a new entry and the cache exceeded max_size_, the eviction walked this global lru_ and could remove the least-recently-used Query belonging to a different partition, so activity in one site’s partition deterministically altered cached state in an unrelated site’s partition. This violated the invariant that a cache partition’s contents and their lifetime must depend only on that partition’s own activity, turning global eviction into a cross-site eviction oracle (an observable discrepancy).

The fix restructures storage so each partition is a Partition object owning its own base_url_map and lru list, threaded on a separate partition_lru_ list of partitions, so eviction is now bounded within a partition. It also caps per-partition entries (kHttpCacheNoVarySearchCacheMaxPartitionEntries) and total partitions (kHttpCacheNoVarySearchCacheMaxPartitions) via UpdateLimits(), ensuring one site can neither evict another site’s entries nor observe a shared global size.

The result is that a lookup now moves both the partition and the matched Query to the head of their respective per-partition lists, keeping all eviction effects local.

Key insight
The single core mistake was partitioning the lookup map by site while leaving the eviction LRU state global, so cross-partition eviction leaked cross-site signal; the fix moves the LRU list (and its size limits) inside each Partition so eviction can never cross the partition boundary.

Attack Path

  1. Establish a baseline The attacker’s site fills a target partition or the shared cache so that the global lru_ list is at or near max_size_, making the next insertion trigger an eviction.
  2. Trigger victim caching The victim visits a target site whose responses populate that site’s partition and become recently-used entries at the head of the global lru_.
  3. Force a cross-partition eviction The attacker inserts entries in its own partition, pushing the global list over the limit so the least-recently-used entry, potentially from the victim’s partition, is evicted.
  4. Probe for the discrepancy The attacker re-requests a URL and observes whether it produced a cache hit or a miss, revealing whether a specific cross-partition entry survived or was evicted.
  5. Infer victim state By repeating the timing of insertions and probes, the attacker deduces details about the victim’s cross-site browsing activity from the eviction pattern.

Impact Assessment

An attacker gains a cross-site information-leak oracle: by observing eviction-driven cache hits and misses they can infer aspects of a victim’s activity on other sites, undermining the site-isolation guarantee of the network cache. This operates in the network service / browser network stack (renderer-observable via request timing and cache behavior) and requires the kHttpCacheNoVarySearch feature to be active with the victim and attacker sharing the same cache instance during overlapping browsing. The severity is medium and the impact is disclosure of a discrepancy rather than memory corruption or code execution.

Changed Functions

FunctionChangeNotes
if
net/http/no_vary_search_cache.cc
modified
max_size_
net/http/no_vary_search_cache.cc
modified
max_partitions_
net/http/no_vary_search_cache.cc
modified
for
net/http/no_vary_search_cache.cc
modified

Files Changed

  • net/base/features.cc
  • net/base/features.h
  • net/http/no_vary_search_cache.cc

Audit Directions

  • Partitioned-map-with-global-metadata
    Audit any cache or store keyed by a site/partition key whose eviction, size counters, or ordering state (LRU lists, counters, timestamps) is kept globally, since that shared state re-couples partitions the key was meant to isolate.
  • Eviction as a side channel
    Treat cross-partition eviction, capacity limits, and hit/miss timing as observable discrepancies, and confirm per-partition caps (like kHttpCacheNoVarySearchCacheMaxPartitionEntries and kHttpCacheNoVarySearchCacheMaxPartitions) bound one origin’s influence on another.
  • Linked-list move correctness
    Review manual base::LinkedList manipulation (MoveToHead, RemoveFromList, InsertBefore) for nodes moved between multiple lists, ensuring an entry is unlinked from its old list before insertion to avoid corruption or leaked cross-list membership.
From 7fa16aedef2f4f4f684f002ce991ad84ad7583d5 Mon Sep 17 00:00:00 2001
From: Nidhi Jaju <nidhijaju@chromium.org>
Date: Wed, 05 Aug 2026 23:09:51 -0700
Subject: [PATCH] Partition NoVarySearchCache LRU eviction state

NoVarySearchCache keys its lookup map by the cache partition key but
previously used a single global LRU list for eviction. An insertion in
one partition could evict the least-recently-used entry from a
different partition, creating a cross-site eviction oracle (XS-Leak).

This CL introduces per-partition LRU lists and size tracking by
structuring the cache partitions to hold their own LRU list.
Additionally, it introduces limits on the number of entries per
partition and the total number of partitions in the cache, controlled
by new feature parameters.

TAG=agy
CONV=37dd2729-a1ee-4517-ab59-e8d622df957f

Bug: 513003268
Change-Id: I5eb0460eabae705a8e60f58f22d607190a878085
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8209500
Commit-Queue: Nidhi Jaju <nidhijaju@chromium.org>
Reviewed-by: Adam Rice <ricea@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1674744}
---

diff --git a/net/base/features.cc b/net/base/features.cc
index 092cbfd..5a4dabc 100644
--- a/net/base/features.cc
+++ b/net/base/features.cc
@@ -667,6 +667,16 @@
                    "max_entries",
                    1000);
 
+BASE_FEATURE_PARAM(size_t,
+                   kHttpCacheNoVarySearchCacheMaxPartitionEntries,
+                   &kHttpCacheNoVarySearch,
+                   100);
+
+BASE_FEATURE_PARAM(size_t,
+                   kHttpCacheNoVarySearchCacheMaxPartitions,
+                   &kHttpCacheNoVarySearch,
+                   100);
+
 BASE_FEATURE_PARAM(bool,
                    kHttpCacheNoVarySearchPersistenceEnabled,
                    &kHttpCacheNoVarySearch,
diff --git a/net/base/features.h b/net/base/features.h
index 283592d0..7ffedb4 100644
--- a/net/base/features.h
+++ b/net/base/features.h
@@ -708,6 +708,13 @@
 NET_EXPORT BASE_DECLARE_FEATURE_PARAM(size_t,
                                       kHttpCacheNoVarySearchCacheMaxEntries);
 
+NET_EXPORT BASE_DECLARE_FEATURE_PARAM(
+    size_t,
+    kHttpCacheNoVarySearchCacheMaxPartitionEntries);
+
+NET_EXPORT BASE_DECLARE_FEATURE_PARAM(size_t,
+                                      kHttpCacheNoVarySearchCacheMaxPartitions);
+
 // Whether persistence is enabled in on-the-record profiles. True by default.
 NET_EXPORT BASE_DECLARE_FEATURE_PARAM(bool,
                                       kHttpCacheNoVarySearchPersistenceEnabled);
diff --git a/net/http/no_vary_search_cache.cc b/net/http/no_vary_search_cache.cc
index 878723d..446ce955 100644
--- a/net/http/no_vary_search_cache.cc
+++ b/net/http/no_vary_search_cache.cc
@@ -24,6 +24,7 @@
 #include "base/time/time.h"
 #include "base/trace_event/trace_event.h"
 #include "base/types/expected_macros.h"
+#include "net/base/features.h"
 #include "net/base/pickle.h"
 #include "net/base/pickle_base_types.h"
 #include "net/http/http_cache.h"
@@ -185,9 +186,12 @@
 
   // Moves this object to the head of `linked_list`.
   void MoveToHead(base::LinkedList<Query>& linked_list) {
-    auto* head = linked_list.head();
-    if (head != this) {
-      MoveBeforeNode(linked_list.head()->value());
+    auto* head_node = linked_list.head();
+    if (head_node != this) {
+      if (next()) {
+        RemoveFromList();
+      }
+      InsertBefore(head_node);
     }
   }
 
@@ -297,22 +301,47 @@
 
 NoVarySearchCache::Queries::Queries(Queries&&) = default;
 
+NoVarySearchCache::Partition::Partition() = default;
+NoVarySearchCache::Partition::~Partition() {
+  if (next()) {
+    CHECK(previous());
+    RemoveFromList();
+  }
+}
+
+NoVarySearchCache::Partition::Partition(Partition&&) = default;
+
+void NoVarySearchCache::Partition::MoveToHead(
+    base::LinkedList<Partition>& linked_list) {
+  auto* head_node = linked_list.head();
+  if (head_node != this) {
+    if (next()) {
+      RemoveFromList();
+    }
+    InsertBefore(head_node);
+  }
+}
+
 NoVarySearchCache::NoVarySearchCache(size_t max_size) : max_size_(max_size) {
   CHECK_GE(max_size_, 1u);
   // We can't serialize if `max_size` won't fit in an int.
   CHECK(base::IsValueInRangeForNumericType<int>(max_size));
+
+  UpdateLimits();
 }
 
 NoVarySearchCache::NoVarySearchCache(NoVarySearchCache&& rhs)
     : partitions_(std::move(rhs.partitions_)),
-      lru_(std::move(rhs.lru_)),
+      partition_lru_(std::move(rhs.partition_lru_)),
       size_(std::exchange(rhs.size_, 0u)),
-      max_size_(rhs.max_size_) {}
+      max_size_(rhs.max_size_),
+      max_partition_size_(rhs.max_partition_size_),
+      max_partitions_(rhs.max_partitions_) {}
 
 NoVarySearchCache::~NoVarySearchCache() {
   partitions_.clear();
-  // Clearing the map should have freed all the Query objects.
-  CHECK(lru_.empty());
+  // Clearing the map should have freed all the Partition and Query objects.
+  CHECK(partition_lru_.empty());
 }
 
 std::optional<NoVarySearchCache::LookupResult> NoVarySearchCache::Lookup(
@@ -344,7 +373,8 @@
     return std::nullopt;
   }
 
-  auto& [cache_partition_key_ref, base_url_map] = *partition_it;
+  auto& [cache_partition_key_ref, partition] = *partition_it;
+  auto& base_url_map = partition.base_url_map;
 
   const std::string_view base_url_view = ExtractBaseURL(url);
   const auto base_url_map_it = base_url_map.find(base_url_view);
@@ -370,8 +400,9 @@
     return std::nullopt;
   }
 
-  // This is a hit. Move to head of `lru_` list.
-  best_match->MoveToHead(lru_);
+  // This is a hit. Move partition and query to head of their LRU lists.
+  partition.MoveToHead(partition_lru_);
+  best_match->MoveToHead(partition.lru);
 
   return LookupResult(best_match->ReconstructOriginalURL(base_url_ref),
                       best_match->CreateEraseHandle());
@@ -413,8 +444,9 @@
   // then erase them.
   // TODO(https://crbug.com/382394774): Make this algorithm more efficient.
   std::vector<Query*> pending_erase;
-  for (auto& [cache_partition_key, base_url_map] : partitions_) {
-    for (auto& [base_url_ref, nvs_data_to_queries_map] : base_url_map) {
+  for (auto& [cache_partition_key, partition] : partitions_) {
+    for (auto& [base_url_ref, nvs_data_to_queries_map] :
+         partition.base_url_map) {
       const GURL base_url(base_url_ref);
       CHECK(base_url.is_valid());
       // DoesUrlMatchFilter() only looks at the origin of the URL, which is why
@@ -481,7 +513,8 @@
     return;
   }
 
-  BaseUrlToNVSDataMap& base_url_map = map_it->second;
+  Partition& partition = map_it->second;
+  BaseUrlToNVSDataMap& base_url_map = partition.base_url_map;
 
   const auto base_url_it = base_url_map.find(base_url);
   if (base_url_it == base_url_map.end()) {
@@ -518,21 +551,24 @@
 }
 
 void NoVarySearchCache::MergeFrom(const NoVarySearchCache& newer) {
-  // We cannot use ForEachQuery() here as we need to iterate through the
-  // `lru_` linked list in reverse order.
-  const auto& newer_lru = newer.lru_;
-  for (auto* node = newer_lru.tail(); node != newer_lru.end();
-       node = node->previous()) {
-    Query* query = node->value();
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/net/http/no_vary_search_cache_unittest.cc b/net/http/no_vary_search_cache_unittest.cc
index 06bd00e..be1cbdb 100644
--- a/net/http/no_vary_search_cache_unittest.cc
+++ b/net/http/no_vary_search_cache_unittest.cc
@@ -535,6 +535,171 @@
   }
 }
 
+TEST_P(NoVarySearchCacheTest, PerPartitionSizeLimitEnforced) {
+  if (!HttpCache::IsSplitCacheEnabled()) {
+    GTEST_SKIP() << "Requires distinct cache partitions.";
+  }
+
+  base::test::ScopedFeatureList feature_list;
+  feature_list.InitWithFeaturesAndParameters(
+      {{features::kHttpCacheNoVarySearch,
+        {{features::kHttpCacheNoVarySearchCacheMaxEntries.name, "10"},
+         {features::kHttpCacheNoVarySearchCacheMaxPartitionEntries.name, "2"},
+         {features::kHttpCacheNoVarySearchCacheMaxPartitions.name, "10"}}}},
+      {});
+
+  NoVarySearchCache custom_cache(10);
+  const SchemefulSite site_a(GURL("https://a.test/"));
+  const SchemefulSite site_b(GURL("https://b.test/"));
+  const NetworkIsolationKey nik_a(site_a, site_a);
+  const NetworkIsolationKey nik_b(site_b, site_b);
+
+  const auto url_a = [](size_t i) {
+    return GURL("https://a.test/" + base::NumberToString(i));
+  };
+  const auto url_b = [](size_t i) {
+    return GURL("https://b.test/" + base::NumberToString(i));
+  };
+
+  for (size_t i = 0; i < 3; ++i) {
+    custom_cache.MaybeInsert(TestRequest(url_a(i), nik_a),
+                             TestHeaders("params"));
+  }
+  EXPECT_EQ(custom_cache.size(), 2u);
+  EXPECT_FALSE(custom_cache.Lookup(TestRequest(url_a(0), nik_a)));
+  EXPECT_TRUE(custom_cache.Lookup(TestRequest(url_a(1), nik_a)));
+  EXPECT_TRUE(custom_cache.Lookup(TestRequest(url_a(2), nik_a)));
+
+  for (size_t i = 0; i < 2; ++i) {
+    custom_cache.MaybeInsert(TestRequest(url_b(i), nik_b),
+                             TestHeaders("params"));
+  }
+  EXPECT_EQ(custom_cache.size(), 4u);
+  EXPECT_TRUE(custom_cache.Lookup(TestRequest(url_b(0), nik_b)));
+  EXPECT_TRUE(custom_cache.Lookup(TestRequest(url_b(1), nik_b)));
+}
+
+TEST_P(NoVarySearchCacheTest, MaxPartitionsLimitEnforced) {
+  if (!HttpCache::IsSplitCacheEnabled()) {
+    GTEST_SKIP() << "Requires distinct cache partitions.";
+  }
+
+  base::test::ScopedFeatureList feature_list;
+  feature_list.InitWithFeaturesAndParameters(
+      {{features::kHttpCacheNoVarySearch,
+        {{features::kHttpCacheNoVarySearchCacheMaxEntries.name, "100"},
+         {features::kHttpCacheNoVarySearchCacheMaxPartitionEntries.name, "10"},
+         {features::kHttpCacheNoVarySearchCacheMaxPartitions.name, "2"}}}},
+      {});
+
+  NoVarySearchCache custom_cache(100);
+  const auto make_nik = [](size_t i) {
+    const SchemefulSite site(
+        GURL("https://site" + base::NumberToString(i) + ".test/"));
+    return NetworkIsolationKey(site, site);
+  };
+  const auto make_url = [](size_t i) {
+    return GURL("https://site" + base::NumberToString(i) + ".test/res");
+  };
+
+  custom_cache.MaybeInsert(TestRequest(make_url(0), make_nik(0)),
+                           TestHeaders("key-order"));
+  custom_cache.MaybeInsert(TestRequest(make_url(1), make_nik(1)),
+                           TestHeaders("key-order"));
+  EXPECT_EQ(custom_cache.size(), 2u);
+
+  custom_cache.MaybeInsert(TestRequest(make_url(2), make_nik(2)),
+                           TestHeaders("key-order"));
+  EXPECT_EQ(custom_cache.size(), 2u);
+  EXPECT_FALSE(custom_cache.Lookup(TestRequest(make_url(0), make_nik(0))));
+  EXPECT_TRUE(custom_cache.Lookup(TestRequest(make_url(1), make_nik(1))));
+  EXPECT_TRUE(custom_cache.Lookup(TestRequest(make_url(2), make_nik(2))));
+}
+
+TEST_P(NoVarySearchCacheTest, LookupUpdatesPartitionLru) {
+  if (!HttpCache::IsSplitCacheEnabled()) {
+    GTEST_SKIP() << "Requires distinct cache partitions.";
+  }
+
+  base::test::ScopedFeatureList feature_list;
+  feature_list.InitWithFeaturesAndParameters(
+      {{features::kHttpCacheNoVarySearch,
+        {{features::kHttpCacheNoVarySearchCacheMaxEntries.name, "100"},
+         {features::kHttpCacheNoVarySearchCacheMaxPartitionEntries.name, "10"},
+         {features::kHttpCacheNoVarySearchCacheMaxPartitions.name, "2"}}}},
+      {});
+
+  NoVarySearchCache custom_cache(100);
+  const auto make_nik = [](size_t i) {
+    const SchemefulSite site(
+        GURL("https://site" + base::NumberToString(i) + ".test/"));
+    return NetworkIsolationKey(site, site);
+  };
+  const auto make_url = [](size_t i) {
+    return GURL("https://site" + base::NumberToString(i) + ".test/res");
+  };
+
+  custom_cache.MaybeInsert(TestRequest(make_url(0), make_nik(0)),
+                           TestHeaders("key-order"));
+  custom_cache.MaybeInsert(TestRequest(make_url(1), make_nik(1)),
+                           TestHeaders("key-order"));
+  EXPECT_EQ(custom_cache.size(), 2u);
+
+  // Lookup partition 0 to make it the most recently used.
+  EXPECT_TRUE(custom_cache.Lookup(TestRequest(make_url(0), make_nik(0))));
+
+  // Insert into partition 2, which should evict partition 1 instead of 0.
+  custom_cache.MaybeInsert(TestRequest(make_url(2), make_nik(2)),
+                           TestHeaders("key-order"));
+  EXPECT_EQ(custom_cache.size(), 2u);
+
+  EXPECT_TRUE(custom_cache.Lookup(TestRequest(make_url(0), make_nik(0))));
+  EXPECT_FALSE(custom_cache.Lookup(TestRequest(make_url(1), make_nik(1))));
+  EXPECT_TRUE(custom_cache.Lookup(TestRequest(make_url(2), make_nik(2))));
+}
+
+TEST_P(NoVarySearchCacheTest, LeastRecentlyUsedPartitionEvictedWhenFull) {
+  if (!HttpCache::IsSplitCacheEnabled()) {
+    GTEST_SKIP() << "Requires distinct cache partitions.";
+  }
+
+  base::test::ScopedFeatureList feature_list;
+  feature_list.InitWithFeaturesAndParameters(
+      {{features::kHttpCacheNoVarySearch,
+        {{features::kHttpCacheNoVarySearchCacheMaxEntries.name, "10"},
+         {features::kHttpCacheNoVarySearchCacheMaxPartitionEntries.name, "2"},
+         {features::kHttpCacheNoVarySearchCacheMaxPartitions.name, "5"}}}},
+      {});
+
+  NoVarySearchCache custom_cache(10);
+
+  const auto make_nik = [](size_t i) {
+    const SchemefulSite site(
+        GURL("https://site" + base::NumberToString(i) + ".test/"));
+    return NetworkIsolationKey(site, site);
+  };
+  const auto make_url = [](size_t i, size_t j) {
+    return GURL("https://site" + base::NumberToString(i) + ".test/res" +
+                base::NumberToString(j));
+  };
+
+  for (size_t i = 0; i < 5; ++i) {
+    custom_cache.MaybeInsert(TestRequest(make_url(i, 0), make_nik(i)),
+                             TestHeaders("key-order"));
+    custom_cache.MaybeInsert(TestRequest(make_url(i, 1), make_nik(i)),
+                             TestHeaders("key-order"));
+  }
+  EXPECT_EQ(custom_cache.size(), 10u);
+
+  custom_cache.MaybeInsert(TestRequest(make_url(5, 0), make_nik(5)),
+                           TestHeaders("key-order"));
+
+  EXPECT_FALSE(custom_cache.Lookup(TestRequest(make_url(0, 0), make_nik(0))));
+  EXPECT_FALSE(custom_cache.Lookup(TestRequest(make_url(0, 1), make_nik(0))));
+  EXPECT_TRUE(custom_cache.Lookup(TestRequest(make_url(1, 0), make_nik(1))));
+  EXPECT_TRUE(custom_cache.Lookup(TestRequest(make_url(5, 0), make_nik(5))));
+}
+
 TEST_P(NoVarySearchCacheTest, DifferentURL) {
   const GURL url1("https://example.com/a?a=b");
   const GURL url2("https://example.com/b?a=b");
Loading diff…

Original Bug Report

reported by vm...@google.com

Cross-Site Eviction Oracle in NoVarySearchCache

Project Fortify, 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 https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: The NoVarySearchCache uses a single global LRU list and size counter across all Network Isolation Key (NIK) partitions. An attacker can detect the number of cache insertions on other sites by observing the eviction of their own entries, bypassing intended cross-site isolation. This vulnerability enables a reliable side channel for tracking user activity across top-level site boundaries.

Affected files:

  • net/http/no_vary_search_cache.cc
  • net/http/no_vary_search_cache.h
  • net/http/http_cache_transaction.cc
  • net/http/http_cache.cc
  • net/base/features.cc

Estimated timestamp from git blame: 2025-01-16

Summary

The NoVarySearchCache in Chromium’s network stack is designed to store the relationship between a URL and a previous response that used the No-Vary-Search (NVS) header. While the cache correctly partitions its lookup map by the NetworkIsolationKey (NIK), its eviction mechanism—specifically the LRU list and entry counter—is global. This shared state allows an attacker to create a cross-site eviction oracle to monitor user activity on other websites.

Root Cause Analysis

In net/http/no_vary_search_cache.h, the cache structure is defined as follows:

// Partitioned lookup map
CachePartitionKeyToBaseUrlMap partitions_;

// Global eviction state
base::LinkedList<Query> lru_;
size_t size_ = 0u;
size_t max_size_;

When a new entry is inserted via DoInsert() (net/http/no_vary_search_cache.cc, line 604), it is added to the global lru_ list and the global size_ is incremented. If size_ exceeds max_size_ (defaulting to 1000 in net/base/features.cc), EvictIfOverfull() is triggered:

void NoVarySearchCache::EvictIfOverfull() {
  CHECK_LE(size_, max_size_ + 1);
  if (size_ == max_size_ + 1) {
    // Remove the globally least-recently-used entry
    EraseQuery(lru_.tail()->value());
  }
}

Because lru_.tail() returns the globally oldest entry regardless of which partition it belongs to, an insertion by a victim site under one NIK will evict an entry belonging to an attacker site under a different NIK.

Potential Attack Scenario

An attacker can exploit this behavior without requiring a compromised renderer:

  1. Priming: The attacker-controlled site fills the NoVarySearchCache to its 1000-entry capacity by fetching resources with the No-Vary-Search header.
  2. Maintenance: The attacker periodically re-fetches these resources. Each hit calls MoveToHead(lru_), ensuring the attacker’s entries remain at the front of the global LRU list.
  3. Observation: When the user visits a victim site that triggers a No-Vary-Search cache insertion, the cache exceeds its limit. The browser evicts the attacker’s globally stalest entry at the tail of the list.
  4. Probing: The attacker later fetches their resources using new query parameters (nonces).
    • If the entry for a resource was evicted, the NVS lookup fails, the URL is not rewritten, and a network request reaches the attacker’s server.
    • If the entry survived, the NVS lookup succeeds, the URL is rewritten to the cached version, and a disk cache hit occurs, preventing a network request.

By observing which nonced requests reach their server, the attacker can precisely count how many NVS-enabled resources were loaded on other sites.

Potential Steps to Reproduce

  1. Navigate to https://attacker.example. The page should perform 1000 fetch() calls to resources that return No-Vary-Search: params and have long-lived caching.
  2. Maintain these entries as fresh by periodically re-fetching them.
  3. In a different top-level context, navigate to https://victim.example, which serves one or more resources with No-Vary-Search headers.
  4. At attacker.example, attempt to fetch the original resources using unique query nonces.
  5. Count the number of requests that hit the attacker’s server; this count will correspond to the number of NVS insertions on the victim site.

Suggested Fix

The eviction state must be partitioned by the CachePartitionKey. Each partition should have its own LRU list and size limit, ensuring that insertions in one partition only evict entries from that same partition. Alternatively, the global max_size_ could be replaced by per-partition caps to prevent one site from monopolizing or observing the global cache state.

Evaluated with Chrome root at commit: b3153093eb3c78c3e88ccf562bcbc20437a04b0e


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

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.

View on issue tracker