CVE-2026-11038
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifservices/network/public/cpp/sri_message_signatures.cc |
modified | |
TEST_Fservices/network/public/cpp/sri_message_signatures_unittest.cc |
modified | |
TEST_Pthird_party/blink/renderer/platform/loader/subresource_integrity_test.cc |
modified |
Files Changed
services/network/public/cpp/sri_message_signatures.ccservices/network/public/cpp/sri_message_signatures_unittest.ccthird_party/blink/renderer/platform/loader/subresource_integrity.ccthird_party/blink/renderer/platform/loader/subresource_integrity_test.ccthird_party/blink/renderer/platform/network/http_parsers.cc
Patch
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; +} +
Regression Test / PoC
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`;
Original Bug Report
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.ccthird_party/blink/renderer/platform/loader/subresource_integrity.ccservices/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
-
Signature Base Construction (
services/network/public/cpp/sri_message_signatures.cc): During signature verification, Chrome constructs a signature base that includes theUnencoded-Digestheader. 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 likeUnencoded-Digest: sha-384=:<digest>:passes Ed25519 verification if legitimately signed by the origin. -
Digest Extraction (
services/network/public/cpp/unencoded_digests.cc): The extraction logic (ParseUnencodedDigestsFromHeaders, lines 48-84) only recognizessha-256andsha-512. If the header contains other algorithms, such assha-384or misspelled keys, the parser skips them usingcontinue(line 63). The resultingdigestsvector is empty. -
Fail-Open Body Verification (
third_party/blink/renderer/platform/loader/subresource_integrity.cc): The Blink-side verification functionSubresourceIntegrity::CheckUnencodedDigests(lines 174-201) iterates over thedigestsvector. Because the vector is empty, the loop does not execute, and the function returnstrue, indicating success. The body goes completely unverified. -
Signature Verification (
third_party/blink/renderer/platform/loader/subresource_integrity.cc): Finally, Blink verifies the signatures inCheckSignaturesImpl(lines 332-391) by ensuring a parsed signature has akeyidmatching 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.
- A developer implements Signature-based SRI:
<script integrity="ed25519-<pubkey>" src="https://cdn.example.com/script.js"></script>. - The legitimate origin server serves the script with an
Unencoded-Digestheader using an unsupported algorithm, such assha-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>:
- 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. - The browser receives the response. Network-level signature verification succeeds because the headers are unchanged.
- Digest parsing extracts an empty list of digests because
sha-384is unsupported. - Blink’s
CheckUnencodedDigestsreceives the empty list, returnstrue, and skips body validation. - 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.