CVE-2026-87539
Overview
Background
- `NoVarySearchCache`
- A
net::HTTP-cache helper that maps a request URL (ignoring query parameters marked non-varying viaNo-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.
Partition so eviction can never cross the partition boundary.Attack Path
- Establish a baseline
The attacker’s site fills a target partition or the shared cache so that the global
lru_list is at or nearmax_size_, making the next insertion trigger an eviction. - 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_. - 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.
- 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.
- 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
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
| Function | Change | Notes |
|---|---|---|
ifnet/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 | |
fornet/http/no_vary_search_cache.cc |
modified |
Files Changed
net/base/features.ccnet/base/features.hnet/http/no_vary_search_cache.cc
Audit Directions
- Partitioned-map-with-global-metadataAudit 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 channelTreat cross-partition eviction, capacity limits, and hit/miss timing as observable discrepancies, and confirm per-partition caps (like
kHttpCacheNoVarySearchCacheMaxPartitionEntriesandkHttpCacheNoVarySearchCacheMaxPartitions) bound one origin’s influence on another. - Linked-list move correctnessReview manual
base::LinkedListmanipulation (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.
Patch
From 7fa16aedef2f4f4f684f002ce991ad84ad7583d5 Mon Sep 17 00:00:00 2001 From: Nidhi Jaju <[email protected]> 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 <[email protected]> Reviewed-by: Adam Rice <[email protected]> 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();
Regression Test / PoC
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");
Original Bug Report
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.ccnet/http/no_vary_search_cache.hnet/http/http_cache_transaction.ccnet/http/http_cache.ccnet/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:
- Priming: The attacker-controlled site fills the
NoVarySearchCacheto its 1000-entry capacity by fetching resources with theNo-Vary-Searchheader. - 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. - Observation: When the user visits a victim site that triggers a
No-Vary-Searchcache insertion, the cache exceeds its limit. The browser evicts the attacker’s globally stalest entry at the tail of the list. - 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
- Navigate to
https://attacker.example. The page should perform 1000fetch()calls to resources that returnNo-Vary-Search: paramsand have long-lived caching. - Maintain these entries as fresh by periodically re-fetching them.
- In a different top-level context, navigate to
https://victim.example, which serves one or more resources withNo-Vary-Searchheaders. - At
attacker.example, attempt to fetch the original resources using unique query nonces. - 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.