Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactImproper certificate validation in CORS
DescriptionImproper certificate validation in CORS
ComponentCORS
Bug ClassCross Origin
Tracker540070236
Fix commite08e3ee28570 (chromium/src) +30/-1
CISA KEVNot listed
CreditedGoogle
Disclosed2026-09-08

Changed Functions

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

Files Changed

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

Original Bug Report

reported by vm...@google.com

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

  1. On an attacker-controlled network, intercept requests to https://victim.example with a self-signed certificate.
  2. In Chrome, navigate to https://victim.example and click “Proceed” on the SSL interstitial warning.
  3. Serve a 200 OK response with the header Use-As-Dictionary: match="/*", id="MITM-PLANTED-12345", accompanied by a valid dictionary body and cache headers.
  4. Close the browser, move the client to a clean network, and restart Chrome.
  5. Navigate to the legitimate https://victim.example and observe that the outgoing request headers contain Dictionary-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.

View on issue tracker