CVE-2026-87551
Overview
Background
- Compression dictionary transport
- a mechanism where a response bearing a
Use-As-Dictionaryheader is persisted so its body can serve as a Brotli/Zstd dictionary that compresses future responses from the same origin/scope. - `SharedDictionaryWriter`
- the object, obtained from
SharedDictionaryStorage::MaybeCreateWriter, that captures a response body into on-disk shared-dictionary storage for later reuse. - `net::IsCertStatusError`
- a helper in
net/cert/cert_status_flags.hthat returns true when a response’scert_statusbitfield carries any fatal TLS certificate error (e.g.CERT_STATUS_AUTHORITY_INVALID). - `cert_status`
- a field on
mojom::URLResponseHeadrecording the TLS certificate validation result for the connection that produced the response.
Root Cause Analysis
In services/network/cors/cors_url_loader.cc, the response-handling path checked only whether a Use-As-Dictionary header was present (if (use_as_dictionary_header)) before calling SharedDictionaryStorage::MaybeCreateWriter and persisting the response body as a shared compression dictionary. That guard never consulted response_head->cert_status, so a response delivered over a connection whose certificate failed validation was still eligible to write a persistent dictionary. This violated the invariant that data from a connection with an untrusted/invalid certificate must not be persisted as trusted, reusable state.
The fix tightens the condition to use_as_dictionary_header && !net::IsCertStatusError(response_head->cert_status), so any fatal certificate error now short-circuits the write and no dictionary is stored. Because IsCertStatusError is evaluated as part of the same if, the MaybeCreateWriter call and the subsequent persistence are simply skipped on the cert-error path, matching the added CertStatusErrorPreventStorage test that sets CERT_STATUS_AUTHORITY_INVALID and asserts the dictionary is absent from storage.
Use-As-Dictionary header and ignored the connection’s certificate validity; the fix adds a !net::IsCertStatusError(response_head->cert_status) term so responses over certificate-invalid connections can no longer poison persistent shared-dictionary storage.Attack Path
- Position on the connection
An attacker able to intercept or serve traffic for a target origin (e.g. a network/MITM attacker or a proxy) produces a response over a TLS connection whose certificate fails validation, yielding a set
cert_statussuch asCERT_STATUS_AUTHORITY_INVALID. - Serve a dictionary directive
The attacker-controlled response includes a
Use-As-Dictionaryheader (with amatchscope) and cacheable directives, so the pre-patch guardif (use_as_dictionary_header)passes despite the certificate error. - Persist the poisoned dictionary
SharedDictionaryStorage::MaybeCreateWriterreturns aSharedDictionaryWriterand the attacker-supplied body is written into persistent shared-dictionary storage as trusted state. - Reuse against future requests Later requests within the dictionary’s match scope select the poisoned dictionary for decompression, letting attacker-controlled bytes influence how subsequent responses are interpreted (inferred), which is the improper-certificate-validation condition the fix closes.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifservices/network/cors/cors_url_loader.cc |
modified | |
TEST_Fservices/network/cors/cors_url_loader_shared_dictionary_unittest.cc |
modified |
Files Changed
services/network/cors/cors_url_loader.ccservices/network/cors/cors_url_loader_shared_dictionary_unittest.cc
Audit Directions
- Persist-decisions that ignore `cert_status`Audit every site where response content or metadata is written to durable storage (caches, dictionaries, prefetch, HSTS-like state) to confirm the write path consults
net::IsCertStatusError(response_head->cert_status)and not just header presence. - Header-presence-only guardsLook for
if (header)conditions that trigger storage or trust decisions without a parallel security/validity check on the connection that delivered the header. - Shared-dictionary lifecycleReview the rest of the compression-dictionary path (
SharedDictionaryStorage::MaybeCreateWriter, dictionary selection/read for later requests, and non-CORS loaders) for analogous places where an invalid certificate, revoked cert, or other connection error should but may not veto persistence or reuse.
Patch
From e08e3ee28570e634bfa6e3a752fcaf9d8aa9f0d7 Mon Sep 17 00:00:00 2001 From: Patrick Meenan <[email protected]> Date: Tue, 28 Jul 2026 18:57:52 -0700 Subject: [PATCH] net: Prevent dictionary writes for cert errors Don't persist compression dictionaries in the case of a TLS certificate error. Bug: 540070236 Change-Id: Idd0ba9cd844bad97f3757a840895a63944f1e778 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8160125 Commit-Queue: Patrick Meenan <[email protected]> Reviewed-by: Tsuyoshi Horo <[email protected]> Cr-Commit-Position: refs/heads/main@{#1669942} --- diff --git a/services/network/cors/cors_url_loader.cc b/services/network/cors/cors_url_loader.cc index 8a7b64d..313c1cb 100644 --- a/services/network/cors/cors_url_loader.cc +++ b/services/network/cors/cors_url_loader.cc @@ -20,6 +20,7 @@ #include "base/types/optional_util.h" #include "net/base/load_flags.h" #include "net/base/request_priority.h" +#include "net/cert/cert_status_flags.h" #include "net/cookies/cookie_partition_key.h" #include "net/cookies/cookie_setting_override.h" #include "net/cookies/cookie_util.h" @@ -629,7 +630,8 @@ std::optional<std::string> use_as_dictionary_header = GetHeaderString( *response_head, shared_dictionary::kUseAsDictionaryHeaderName); - if (use_as_dictionary_header) { + if (use_as_dictionary_header && + !net::IsCertStatusError(response_head->cert_status)) { base::expected<scoped_refptr<SharedDictionaryWriter>, mojom::SharedDictionaryError> writer_or_error = SharedDictionaryStorage::MaybeCreateWriter( 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 61fb57f..f9080a9 100644 --- a/services/network/cors/cors_url_loader_shared_dictionary_unittest.cc +++ b/services/network/cors/cors_url_loader_shared_dictionary_unittest.cc @@ -4,7 +4,9 @@ #include "base/feature_list.h" #include "mojo/public/cpp/system/data_pipe_utils.h" +#include "net/cert/cert_status_flags.h" #include "net/cookies/site_for_cookies.h" +#include "net/http/http_response_headers.h" #include "services/network/cors/cors_url_loader.h" #include "services/network/cors/cors_url_loader_test_util.h" #include "services/network/network_context.h" @@ -196,6 +198,31 @@ CheckDictionaryInStorage(/*expect_exists=*/true); } +TEST_F(CorsURLLoaderSharedDictionaryTest, CertStatusErrorPreventStorage) { + ResetFactory(); + ResourceRequest request = CreateResourceRequest(); + request.mode = mojom::RequestMode::kSameOrigin; + CreateLoaderAndStart(request); + RunUntilCreateLoaderAndStartCalled(); + + CreateDataPipeAndWriteTestData(); + auto response_head = mojom::URLResponseHead::New(); + response_head->headers = base::MakeRefCounted<net::HttpResponseHeaders>( + "HTTP/1.1 200 OK\n" + "Use-As-Dictionary: match=\"/path*\"\n" + "Cache-Control: max-age=2592000\n"); + response_head->cert_status = net::CERT_STATUS_AUTHORITY_INVALID; + NotifyLoaderClientOnReceiveResponse(std::move(response_head), + std::move(consumer_handle_)); + NotifyLoaderClientOnComplete(net::OK); + producer_handle_.reset(); + + RunUntilComplete(); + EXPECT_EQ(net::OK, client().completion_status().error_code); + + CheckDictionaryInStorage(/*expect_exists=*/false); +} + TEST_F(CorsURLLoaderSharedDictionaryTest, SameOriginUrlNoCorsModeRequest) { ResetFactory();
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 61fb57f..f9080a9 100644
--- a/services/network/cors/cors_url_loader_shared_dictionary_unittest.cc
+++ b/services/network/cors/cors_url_loader_shared_dictionary_unittest.cc
@@ -4,7 +4,9 @@
#include "base/feature_list.h"
#include "mojo/public/cpp/system/data_pipe_utils.h"
+#include "net/cert/cert_status_flags.h"
#include "net/cookies/site_for_cookies.h"
+#include "net/http/http_response_headers.h"
#include "services/network/cors/cors_url_loader.h"
#include "services/network/cors/cors_url_loader_test_util.h"
#include "services/network/network_context.h"
@@ -196,6 +198,31 @@
CheckDictionaryInStorage(/*expect_exists=*/true);
}
+TEST_F(CorsURLLoaderSharedDictionaryTest, CertStatusErrorPreventStorage) {
+ ResetFactory();
+ ResourceRequest request = CreateResourceRequest();
+ request.mode = mojom::RequestMode::kSameOrigin;
+ CreateLoaderAndStart(request);
+ RunUntilCreateLoaderAndStartCalled();
+
+ CreateDataPipeAndWriteTestData();
+ auto response_head = mojom::URLResponseHead::New();
+ response_head->headers = base::MakeRefCounted<net::HttpResponseHeaders>(
+ "HTTP/1.1 200 OK\n"
+ "Use-As-Dictionary: match=\"/path*\"\n"
+ "Cache-Control: max-age=2592000\n");
+ response_head->cert_status = net::CERT_STATUS_AUTHORITY_INVALID;
+ NotifyLoaderClientOnReceiveResponse(std::move(response_head),
+ std::move(consumer_handle_));
+ NotifyLoaderClientOnComplete(net::OK);
+ producer_handle_.reset();
+
+ RunUntilComplete();
+ EXPECT_EQ(net::OK, client().completion_status().error_code);
+
+ CheckDictionaryInStorage(/*expect_exists=*/false);
+}
+
TEST_F(CorsURLLoaderSharedDictionaryTest, SameOriginUrlNoCorsModeRequest) {
ResetFactory();
Original Bug Report
Lack of SSL certificate validation in Use-As-Dictionary writer path allows MITM persistence
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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: The write path for the Use-As-Dictionary response header does not inspect the connection’s SSL certificate status or verify if the certificate is issued by a known root. This potentially allows an active network attacker on a certificate-error HTTPS connection (following an interstitial click-through) to persist arbitrary, origin-keyed dictionary IDs. These IDs will be transmitted back to the legitimate server on subsequent clean, valid TLS connections.
Affected files:
services/network/cors/cors_url_loader.ccservices/network/shared_dictionary/shared_dictionary_storage.cc
Estimated timestamp from git blame: 2023-04-12
Root Cause
When processing a response containing the Use-As-Dictionary header, CorsURLLoader::OnReceiveResponse delegates writer creation to SharedDictionaryStorage::MaybeCreateWriter without inspecting the connection’s ssl_info or certificate status:
// services/network/cors/cors_url_loader.cc:651-667
std::optional<std::string> use_as_dictionary_header = GetHeaderString(
*response_head, shared_dictionary::kUseAsDictionaryHeaderName);
if (use_as_dictionary_header) {
base::expected<scoped_refptr<SharedDictionaryWriter>,
mojom::SharedDictionaryError>
writer_or_error = SharedDictionaryStorage::MaybeCreateWriter(
*use_as_dictionary_header,
request_.shared_dictionary_writer_enabled,
shared_dictionary_storage_.get(), request_.mode, response_tainting_,
request_.url, response_head->request_time,
response_head->response_time, *response_head->headers,
response_head->was_fetched_via_cache,
base::BindOnce(
&SharedDictionaryAccessChecker::CheckAllowedToWriteAndReport, ...));
Inside SharedDictionaryStorage::MaybeCreateWriter (services/network/shared_dictionary/shared_dictionary_storage.cc), checks are performed on the request mode, writer-enabled state, secure-context storage, and response tainting, but the function does not inspect connection security flags or ssl_info.
By contrast, other persistent origin-keyed network state features prevent registration on certificate-error connections. For example, Strict Transport Security (HSTS) and Device Bound Session Credentials (DBSC) both explicitly abort processing if a certificate error is present:
// net/url_request/url_request_http_job.cc:1168 and :1192
if (!ssl_info.is_valid() || IsCertStatusError(ssl_info.cert_status)) {
return;
}
Additionally, the shared dictionary read path carries a known-root certificate constraint (net/shared_dictionary/shared_dictionary_network_transaction.cc:250-255), which is checked when utilizing a registered dictionary on subsequent connections. However, because the write path is not gated similarly, an invalid or untrusted certificate session can successfully populate on-disk persistent dictionary state.
Potential Impact
An active network attacker who successfully convinces a user to click through an SSL/TLS interstitial warning on https://victim.example can write and persist an arbitrary compression dictionary payload and a custom Dictionary-ID (up to 1024 bytes) for the victim’s origin.
On subsequent navigations to the legitimate https://victim.example over a clean network with a valid root certificate, the browser’s SharedDictionaryNetworkTransaction will retrieve the registered dictionary and attach the attacker-chosen Dictionary-ID header to all matching outgoing requests. This violates the security boundary that prevents untrusted certificate connections from setting persistent origin-keyed network state.
Suggested Reproduction Steps (Potential)
Note: Our tooling does not currently have the capability to run code directly, so these are potential steps to illustrate the behavior:
- On an attacker-controlled network, intercept requests to
https://victim.examplewith a self-signed certificate. - In Chrome, navigate to
https://victim.exampleand click “Proceed” on the SSL interstitial warning. - Serve a
200 OKresponse with the headerUse-As-Dictionary: match="/*", id="MITM-PLANTED-12345", accompanied by a valid dictionary body and cache headers. - Close the browser, move the client to a clean network, and restart Chrome.
- Navigate to the legitimate
https://victim.exampleand observe that the outgoing request headers containDictionary-ID: "MITM-PLANTED-12345"over the genuine secure connection.
Suggested Fix
Ensure that dictionaries are not registered on connections with certificate errors. In CorsURLLoader::OnReceiveResponse (or inside SharedDictionaryStorage::MaybeCreateWriter), evaluate response_head->cert_status or inspect the response’s ssl_info to ensure there are no certificate errors prior to writer creation:
if (response_head->ssl_info && net::IsCertStatusError(response_head->ssl_info->cert_status)) {
// Reject dictionary writer creation
}
Evaluated with Chrome root at commit: 94d9235ebe3b7276e5284f0dc5d55577ff949908
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.