Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in Subresource Integrity
DescriptionInsufficient validation of untrusted input in Subresource Integrity
ComponentSubresource Integrity
Bug ClassLogic Error
Tracker498080391
Fix commit13b52743e621 (chromium/src) +133/-4
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
if
services/network/public/cpp/sri_message_signatures.cc
modified
TEST_F
services/network/public/cpp/sri_message_signatures_unittest.cc
modified
TEST_P
third_party/blink/renderer/platform/loader/subresource_integrity_test.cc
modified

Files Changed

  • services/network/public/cpp/sri_message_signatures.cc
  • services/network/public/cpp/sri_message_signatures_unittest.cc
  • third_party/blink/renderer/platform/loader/subresource_integrity.cc
  • third_party/blink/renderer/platform/loader/subresource_integrity_test.cc
  • third_party/blink/renderer/platform/network/http_parsers.cc
From 13b52743e6214d40a7eddf7d099616116f4c7954 Mon Sep 17 00:00:00 2001
From: Mike West <[email protected]>
Date: Fri, 10 Apr 2026 04:07:02 -0700
Subject: [PATCH] [SRI Message Signatures] Require an enforceable `Unencoded-Digest`.

If the `Unencoded-Digest` header contains an unknown algorithm, we can't
enforce its assertions on the content we download. This enables forward
compatibility with new algorithms, but also opens a hole in signature
checks, insofar as the signature over an unenforceable digest is equally
unenforceable. Here, we follow the spec change in [1] to require
enforcability.

[1]: https://github.com/WICG/signature-based-sri/commit/c6300af60c5ece3a79cc18a9f9f4af5828c393ad

Bug: 498080391
Change-Id: I7422e261831cd1af0da9fb97c603350733cff262
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7734936
Reviewed-by: Antonio Sartori <[email protected]>
Commit-Queue: Mike West <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1612754}
---

diff --git a/services/network/public/cpp/sri_message_signatures.cc b/services/network/public/cpp/sri_message_signatures.cc
index 8445b1f..d515adec 100644
--- a/services/network/public/cpp/sri_message_signatures.cc
+++ b/services/network/public/cpp/sri_message_signatures.cc
@@ -862,6 +862,16 @@
   }
 
   if (passed_validation) {
+    // If we have signatures that matched expected keys, they MUST have a
+    // usable unencoded-digest. ParseSRIMessageSignaturesFromHeaders enforces
+    // that all parsed signatures cover `unencoded-digest`. If we don't have
+    // any supported digests, we can't verify the body, so we must fail.
+    if (!expected_public_keys.empty() &&
+        parsed_headers->signatures.size() > 0 &&
+        (!response.unencoded_digests ||
+         response.unencoded_digests->digests.empty())) {
+      return mojom::BlockedByResponseReason::kSRIMessageSignatureMismatch;
+    }
     return std::nullopt;
   }
   return mojom::BlockedByResponseReason::kSRIMessageSignatureMismatch;
diff --git a/services/network/public/cpp/sri_message_signatures_unittest.cc b/services/network/public/cpp/sri_message_signatures_unittest.cc
index b4b66a29..0539caa 100644
--- a/services/network/public/cpp/sri_message_signatures_unittest.cc
+++ b/services/network/public/cpp/sri_message_signatures_unittest.cc
@@ -21,6 +21,7 @@
 #include "net/url_request/url_request_context_builder.h"
 #include "net/url_request/url_request_test_util.h"
 #include "services/network/public/cpp/features.h"
+#include "services/network/public/cpp/unencoded_digests.h"
 #include "services/network/public/mojom/sri_message_signature.mojom.h"
 #include "testing/gmock/include/gmock/gmock.h"
 #include "testing/gtest/include/gtest/gtest.h"
@@ -1634,6 +1635,7 @@
                                          std::string_view input) {
     auto head = mojom::URLResponseHead::New();
     head->headers = Headers(digest, signature, input);
+    head->unencoded_digests = ParseUnencodedDigestsFromHeaders(*head->headers);
     return head;
   }
 };
@@ -1698,6 +1700,26 @@
             result.value());
 }
 
+TEST_F(SRIMessageSignatureEnforcementTest,
+       ValidHeadersButEmptyUnencodedDigestsFailOpen) {
+  const std::vector<uint8_t> public_key = *base::Base64Decode(kPublicKey);
+
+  auto head = ResponseHead(kValidDigestHeader, kValidSignatureHeader,
+                           kValidSignatureInputHeader);
+
+  // Manually clear the digests to simulate a header that was present but
+  // didn't contain any supported algorithms.
+  head->unencoded_digests->digests.clear();
+
+  auto result =
+      MaybeBlockResponseForSRIMessageSignature(request(), *head, {public_key});
+
+  // This should now be blocked.
+  EXPECT_TRUE(result.has_value());
+  EXPECT_EQ(mojom::BlockedByResponseReason::kSRIMessageSignatureMismatch,
+            result.value());
+}
+
 TEST_F(SRIMessageSignatureEnforcementTest, MismatchedHeaders) {
   const char* wrong_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
   const char* wrong_signature =
diff --git a/third_party/blink/renderer/platform/loader/subresource_integrity.cc b/third_party/blink/renderer/platform/loader/subresource_integrity.cc
index 7b6226c..e8443b2 100644
--- a/third_party/blink/renderer/platform/loader/subresource_integrity.cc
+++ b/third_party/blink/renderer/platform/loader/subresource_integrity.cc
@@ -9,6 +9,7 @@
 #include "base/strings/string_view_util.h"
 #include "services/network/public/mojom/fetch_api.mojom-blink.h"
 #include "services/network/public/mojom/sri_message_signature.mojom-blink.h"
+#include "services/network/public/mojom/unencoded_digest.mojom-blink.h"
 #include "third_party/blink/public/mojom/use_counter/metrics/web_feature.mojom-blink.h"
 #include "third_party/blink/public/platform/web_crypto.h"
 #include "third_party/blink/public/platform/web_crypto_algorithm.h"
@@ -361,6 +362,22 @@
   Vector<network::mojom::blink::SRIMessageSignaturePtr> signatures =
       std::move(ParseSRIMessageSignaturesFromHeaders(raw_headers)->signatures);
 
+  // Any SRI signature MUST cover `unencoded-digest`. If we have signatures,
+  // but we don't have any usable `unencoded-digest` assertions, we must fail.
+  if (!signatures.empty()) {
+    network::mojom::blink::UnencodedDigestsPtr unencoded_digests =
+        ParseUnencodedDigestsFromHeaders(raw_headers);
+    if (unencoded_digests->digests.empty()) {
+      integrity_report.AddConsoleErrorMessage(
+          StrCat({"Subresource Integrity: The resource at `",
+                  resource_url.ElidedString(),
+                  "` was signed, but did not provide any supported "
+                  "`unencoded-digest` assertions. The resource has been "
+                  "blocked."}));
+      return false;
+    }
+  }
+
   // This would be caught below, but we'll exit early for unsigned resources
   // so we can provide a better error message in the console.
   if (signatures.empty() && !integrity_list.empty()) {
diff --git a/third_party/blink/renderer/platform/loader/subresource_integrity_test.cc b/third_party/blink/renderer/platform/loader/subresource_integrity_test.cc
index f60ffef..2677266 100644
--- a/third_party/blink/renderer/platform/loader/subresource_integrity_test.cc
+++ b/third_party/blink/renderer/platform/loader/subresource_integrity_test.cc
@@ -890,6 +890,42 @@
       << "Resource variant";
 }
 
+TEST_P(SubresourceIntegritySignatureTest,
+       CheckValidSignatureButEmptyUnencodedDigest) {
+  // Known-good message signature constants:
+  String kPublicKey = "JrQLj5P/89iXES9+vFgrIy29clF9CC/oPPsw3c5D0bs=";
+  // Use an unsupported algorithm (sha-384).
+  String kUnsupportedDigestHeader =
+      "sha-384=:uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek=:";
+  String kValidSignatureInputHeader =
+      "signature=(\"unencoded-digest\";sf);"
+      "keyid=\"JrQLj5P/89iXES9+vFgrIy29clF9CC/oPPsw3c5D0bs=\";tag=\"sri\"";
+  String kValidSignatureHeader =
+      "signature=:gHim9e5Pk2H7c9BStOmxSmkyc8+ioZgoxynu3d4INAT4dwfj5LhvaV9DFnEQ9"
+      "p7C0hzW4o4Qpkm5aApd6WLLCw==:";
+
+  String raw_headers =
+      "HTTP/1.1 200 OK\r\n"
+      "Unencoded-Digest: " +
+      kUnsupportedDigestHeader +
+      "\r\n"
+      "Signature-Input: " +
+      kValidSignatureInputHeader +
+      "\r\n"
+      "Signature: " +
+      kValidSignatureHeader + "\r\n\r\n";
+
+  IntegrityReport integrity_report;
+  IntegrityMetadataSet metadata_set;
+  metadata_set.public_keys = {
+      CreateIntegrityMetadata(kPublicKey, IntegrityAlgorithm::kEd25519)};
+
+  // This should now FAIL because unencoded-digest algorithm is unsupported.
+  EXPECT_FALSE(SubresourceIntegrity::CheckSubresourceIntegrity(
+      metadata_set, /*buffer=*/nullptr, sec_url, FetchResponseType::kCors,
+      raw_headers, /*feature_context=*/nullptr, integrity_report));
+}
+
 TEST_P(SubresourceIntegritySignatureTest, Inline_NoSignatures) {
   String kIntegrity = "ed25519-JrQLj5P/89iXES9+vFgrIy29clF9CC/oPPsw3c5D0bs=";
   String kSource = "alert(1);";
diff --git a/third_party/blink/renderer/platform/network/http_parsers.cc b/third_party/blink/renderer/platform/network/http_parsers.cc
index 75a0133..d0b1b02 100644
--- a/third_party/blink/renderer/platform/network/http_parsers.cc
+++ b/third_party/blink/renderer/platform/network/http_parsers.cc
@@ -52,6 +52,7 @@
 #include "services/network/public/cpp/parsed_headers.h"
 #include "services/network/public/cpp/sri_message_signatures.h"
 #include "services/network/public/cpp/timing_allow_origin_parser.h"
+#include "services/network/public/cpp/unencoded_digests.h"
 #include "services/network/public/mojom/connection_allowlist.mojom-blink.h"
 #include "services/network/public/mojom/integrity_policy.mojom-blink.h"
 #include "services/network/public/mojom/no_vary_search.mojom-blink-forward.h"
@@ -60,6 +61,7 @@
 #include "services/network/public/mojom/sri_message_signature.mojom-blink.h"
 #include "services/network/public/mojom/supports_loading_mode.mojom-blink.h"
 #include "services/network/public/mojom/timing_allow_origin.mojom-blink.h"
+#include "services/network/public/mojom/unencoded_digest.mojom-blink.h"
 #include "third_party/blink/public/common/features.h"
 #include "third_party/blink/public/common/mime_util/mime_util.h"
 #include "third_party/blink/renderer/platform/loader/fetch/resource_response.h"
@@ -115,6 +117,10 @@
   return static_cast<blink::LoadingMode>(in);
 }
 
+blink::UnencodedDigestIssue ConvertToBlink(UnencodedDigestIssue in) {
+  return in;
+}
+
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/services/network/public/cpp/sri_message_signatures_unittest.cc b/services/network/public/cpp/sri_message_signatures_unittest.cc
index b4b66a29..0539caa 100644
--- a/services/network/public/cpp/sri_message_signatures_unittest.cc
+++ b/services/network/public/cpp/sri_message_signatures_unittest.cc
@@ -21,6 +21,7 @@
 #include "net/url_request/url_request_context_builder.h"
 #include "net/url_request/url_request_test_util.h"
 #include "services/network/public/cpp/features.h"
+#include "services/network/public/cpp/unencoded_digests.h"
 #include "services/network/public/mojom/sri_message_signature.mojom.h"
 #include "testing/gmock/include/gmock/gmock.h"
 #include "testing/gtest/include/gtest/gtest.h"
@@ -1634,6 +1635,7 @@
                                          std::string_view input) {
     auto head = mojom::URLResponseHead::New();
     head->headers = Headers(digest, signature, input);
+    head->unencoded_digests = ParseUnencodedDigestsFromHeaders(*head->headers);
     return head;
   }
 };
@@ -1698,6 +1700,26 @@
             result.value());
 }
 
+TEST_F(SRIMessageSignatureEnforcementTest,
+       ValidHeadersButEmptyUnencodedDigestsFailOpen) {
+  const std::vector<uint8_t> public_key = *base::Base64Decode(kPublicKey);
+
+  auto head = ResponseHead(kValidDigestHeader, kValidSignatureHeader,
+                           kValidSignatureInputHeader);
+
+  // Manually clear the digests to simulate a header that was present but
+  // didn't contain any supported algorithms.
+  head->unencoded_digests->digests.clear();
+
+  auto result =
+      MaybeBlockResponseForSRIMessageSignature(request(), *head, {public_key});
+
+  // This should now be blocked.
+  EXPECT_TRUE(result.has_value());
+  EXPECT_EQ(mojom::BlockedByResponseReason::kSRIMessageSignatureMismatch,
+            result.value());
+}
+
 TEST_F(SRIMessageSignatureEnforcementTest, MismatchedHeaders) {
   const char* wrong_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
   const char* wrong_signature =
diff --git a/third_party/blink/renderer/platform/loader/subresource_integrity_test.cc b/third_party/blink/renderer/platform/loader/subresource_integrity_test.cc
index f60ffef..2677266 100644
--- a/third_party/blink/renderer/platform/loader/subresource_integrity_test.cc
+++ b/third_party/blink/renderer/platform/loader/subresource_integrity_test.cc
@@ -890,6 +890,42 @@
       << "Resource variant";
 }
 
+TEST_P(SubresourceIntegritySignatureTest,
+       CheckValidSignatureButEmptyUnencodedDigest) {
+  // Known-good message signature constants:
+  String kPublicKey = "JrQLj5P/89iXES9+vFgrIy29clF9CC/oPPsw3c5D0bs=";
+  // Use an unsupported algorithm (sha-384).
+  String kUnsupportedDigestHeader =
+      "sha-384=:uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek=:";
+  String kValidSignatureInputHeader =
+      "signature=(\"unencoded-digest\";sf);"
+      "keyid=\"JrQLj5P/89iXES9+vFgrIy29clF9CC/oPPsw3c5D0bs=\";tag=\"sri\"";
+  String kValidSignatureHeader =
+      "signature=:gHim9e5Pk2H7c9BStOmxSmkyc8+ioZgoxynu3d4INAT4dwfj5LhvaV9DFnEQ9"
+      "p7C0hzW4o4Qpkm5aApd6WLLCw==:";
+
+  String raw_headers =
+      "HTTP/1.1 200 OK\r\n"
+      "Unencoded-Digest: " +
+      kUnsupportedDigestHeader +
+      "\r\n"
+      "Signature-Input: " +
+      kValidSignatureInputHeader +
+      "\r\n"
+      "Signature: " +
+      kValidSignatureHeader + "\r\n\r\n";
+
+  IntegrityReport integrity_report;
+  IntegrityMetadataSet metadata_set;
+  metadata_set.public_keys = {
+      CreateIntegrityMetadata(kPublicKey, IntegrityAlgorithm::kEd25519)};
+
+  // This should now FAIL because unencoded-digest algorithm is unsupported.
+  EXPECT_FALSE(SubresourceIntegrity::CheckSubresourceIntegrity(
+      metadata_set, /*buffer=*/nullptr, sec_url, FetchResponseType::kCors,
+      raw_headers, /*feature_context=*/nullptr, integrity_report));
+}
+
 TEST_P(SubresourceIntegritySignatureTest, Inline_NoSignatures) {
   String kIntegrity = "ed25519-JrQLj5P/89iXES9+vFgrIy29clF9CC/oPPsw3c5D0bs=";
   String kSource = "alert(1);";
diff --git a/third_party/blink/web_tests/external/wpt/subresource-integrity/integrity-policy/tentative/signature.https.html b/third_party/blink/web_tests/external/wpt/subresource-integrity/integrity-policy/tentative/signature.https.html
index d54a6df..cc15beb 100644
--- a/third_party/blink/web_tests/external/wpt/subresource-integrity/integrity-policy/tentative/signature.https.html
+++ b/third_party/blink/web_tests/external/wpt/subresource-integrity/integrity-policy/tentative/signature.https.html
@@ -8,11 +8,19 @@
 <body>
 <script>
   promise_test(async () => {
-    // Generated using https://sha2.it/ed25519.html in a browser that supports Signature-Based SRI (e.g. Chrome 136+, with Experimental Web Platform Features enabled)
+    // Generated using https://sha2.it/ed25519.html in a browser that supports Signature-Based SRI
+    //
+    // The response over which the digest is generated is `window.ran = true;\n`, and the "signature
+    // base" is:
+    //
+    // ```
+    // "unencoded-digest";sf: sha-256=:JWql9hchAH1Rc/vo5mdgD2Tw1oSH1HlByNgKGDxSh9Y=:
+    // "@signature-params": ("unencoded-digest";sf);keyid="JrQLj5P/89iXES9+vFgrIy29clF9CC/oPPsw3c5D0bs=";tag="ed25519-integrity"
+    // ```
     const signature = encodeURIComponent(
-      'header(Unencoded-Digest, sha-384=:tqyFpeo21WFM8HDeUtLqH20GUq\/q3D1R6mqTzW3RtyTZ3dAYZJhC1wUcnkgOE2ak:)' +
-      '|header(Signature-Input, signature=\\("unencoded-digest";sf\\); keyid="JrQLj5P\/89iXES9+vFgrIy29clF9CC\/oPPsw3c5D0bs="; tag="sri")' +
-      '|header(Signature, signature=:qM19uLskHm2TQG5LJcH/hY0n0BWWzYOJztVWYlwk0cZb3u0JdgUMre1J4Jn8Tma0x2u5/kPBfbXRMbB+X+vTBw==:)');
+      'header(Unencoded-Digest, sha-256=:JWql9hchAH1Rc/vo5mdgD2Tw1oSH1HlByNgKGDxSh9Y=:)' +
+      '|header(Signature-Input, signature=\\("unencoded-digest";sf\\); keyid="JrQLj5P\/89iXES9+vFgrIy29clF9CC\/oPPsw3c5D0bs="; tag="ed25519-integrity")' +
+      '|header(Signature, signature=:wst4wjY8b6fzAKf0bnxEHjbZnUnOB9v9DqXLZBFKiogHlLzEe2mELvhFAzCZrtKSy3gO5jRv6NzaLTUcYQWXAA==:)');
 
     const REMOTE_EXECUTOR =
       `/common/dispatcher/remote-executor.html`;
Loading diff…

Original Bug Report

reported by [email protected]

Signature-based SRI bypass via unsupported hash algorithms in Unencoded-Digest

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: Chrome’s experimental Signature-based Subresource Integrity (SRI) implementation skips resource body verification when the Unencoded-Digest header uses unsupported hash algorithms (e.g., sha-384). This allows an attacker to bypass SRI by modifying the resource body while leaving the legitimately signed headers intact.

Affected files:

  • services/network/public/cpp/unencoded_digests.cc
  • third_party/blink/renderer/platform/loader/subresource_integrity.cc
  • services/network/public/cpp/sri_message_signatures.cc

Estimated timestamp from git blame: 2025-07-31

Vulnerability Summary

A logic error in Chrome’s experimental Signature-based Subresource Integrity (SRI) implementation leads to a “fail-open” scenario when a resource is served with a signed Unencoded-Digest header containing unrecognized hash algorithms.

Although the browser successfully validates the Ed25519 signature over the response headers, it fails to perform any integrity check on the resource body if it cannot extract a supported digest (currently only SHA-256 and SHA-512 are supported). This allows an on-path attacker or compromised CDN to replace the resource body with arbitrary malicious code, bypassing the security guarantees of SRI.

Technical Analysis

  1. Signature Base Construction (services/network/public/cpp/sri_message_signatures.cc): During signature verification, Chrome constructs a signature base that includes the Unencoded-Digest header. The code (lines 743-752) serializes the dictionary into the base without validating whether the hash algorithms specified within the header are actually supported. Thus, a signature over a header like Unencoded-Digest: sha-384=:<digest>: passes Ed25519 verification if legitimately signed by the origin.

  2. Digest Extraction (services/network/public/cpp/unencoded_digests.cc): The extraction logic (ParseUnencodedDigestsFromHeaders, lines 48-84) only recognizes sha-256 and sha-512. If the header contains other algorithms, such as sha-384 or misspelled keys, the parser skips them using continue (line 63). The resulting digests vector is empty.

  3. Fail-Open Body Verification (third_party/blink/renderer/platform/loader/subresource_integrity.cc): The Blink-side verification function SubresourceIntegrity::CheckUnencodedDigests (lines 174-201) iterates over the digests vector. Because the vector is empty, the loop does not execute, and the function returns true, indicating success. The body goes completely unverified.

  4. Signature Verification (third_party/blink/renderer/platform/loader/subresource_integrity.cc): Finally, Blink verifies the signatures in CheckSignaturesImpl (lines 332-391) by ensuring a parsed signature has a keyid matching the expected public key from the <script> tag. Because the attacker left the headers untouched, this check passes.

Suggested Exploitation Steps (Theoretical)

Note: These are potential steps as we have not verified them with a working exploit.

  1. A developer implements Signature-based SRI: <script integrity="ed25519-<pubkey>" src="https://cdn.example.com/script.js"></script>.
  2. The legitimate origin server serves the script with an Unencoded-Digest header using an unsupported algorithm, such as sha-384, and correctly signs it.
    • Unencoded-Digest: sha-384=:<valid_sha384_digest>:
    • Signature-Input: sig=("unencoded-digest";sf);keyid="<pubkey>";tag="ed25519-integrity"
    • Signature: sig=:<valid_signature>:
  3. An attacker in a MITM position intercepts the request and replaces the script body with malicious JavaScript (e.g., alert(1)), leaving the headers completely unmodified.
  4. The browser receives the response. Network-level signature verification succeeds because the headers are unchanged.
  5. Digest parsing extracts an empty list of digests because sha-384 is unsupported.
  6. Blink’s CheckUnencodedDigests receives the empty list, returns true, and skips body validation.
  7. The malicious script is executed, resulting in Cross-Site Scripting (XSS).

Recommendation

If an Unencoded-Digest header is required for signature validation, the system should fail closed if no supported digests can be extracted from it.

Either MaybeBlockResponseForSRIMessageSignature or ParseUnencodedDigestsFromHeaders should be updated to ensure that if the unencoded-digest component is covered by the signature, the resulting list of extracted, supported digests is non-empty. Alternatively, SubresourceIntegrity::CheckUnencodedDigests could be modified to fail if the unencoded_digests vector is empty but a signature validation was performed.

Evaluated with Chrome root at commit: ff3d2b74fa39431785bd60e51463b08fcc71ee33


Results from 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. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker