fix(sri): preserve supported hashes with malformed padding

Keep supported hash metadata through verification so misplaced or excess
base64 padding cannot disable integrity checks or select a weaker algorithm.
Accept matching noncanonical base64 without changing encoded metadata used
by CSP and preload consumption.

Cover script, preload, CSP, and Window/Worker fetch consumers. Validate with
cargo fmt, workspace Clippy, and all 19,886 nextest tests, plus browser
comparisons and SRI/preload WPT regressions.
This commit is contained in:
ldm0
2026-10-11 18:06:41 +08:00
parent de2d6a3ed9
commit fea16d3dfa
4 changed files with 34 additions and 5 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ async function () {
const integrity = await (async () => {return (async () => {
const rows=[];
for (const cache of ['max-age=60','no-store']) for (const phase of ['pending','complete'])
for (const [name,preloadIntegrity,consumerIntegrity,preloadEvent,consumerEvent,requests] of [["good-empty", "sha384-l+rtjjQWlmlLFL2PC8iIPwnXbiW45bZ6LU1ERjlAxYKUFswREZzY3pPyoT0RsvxJ", "", "load", "load", 1], ["bad-empty", "sha384-AAAA", "", "error", "error", 1], ["bad-same", "sha384-AAAA", "sha384-AAAA", "error", "error", 1], ["bad-good", "sha384-AAAA", "sha384-l+rtjjQWlmlLFL2PC8iIPwnXbiW45bZ6LU1ERjlAxYKUFswREZzY3pPyoT0RsvxJ", "error", "load", 2], ["good-bad", "sha384-l+rtjjQWlmlLFL2PC8iIPwnXbiW45bZ6LU1ERjlAxYKUFswREZzY3pPyoT0RsvxJ", "sha384-AAAA", "load", "error", 2], ["good-options", "sha384-l+rtjjQWlmlLFL2PC8iIPwnXbiW45bZ6LU1ERjlAxYKUFswREZzY3pPyoT0RsvxJ?ignored=option", "sha384-l+rtjjQWlmlLFL2PC8iIPwnXbiW45bZ6LU1ERjlAxYKUFswREZzY3pPyoT0RsvxJ", "load", "load", 1], ["good-different-algorithm", "sha384-l+rtjjQWlmlLFL2PC8iIPwnXbiW45bZ6LU1ERjlAxYKUFswREZzY3pPyoT0RsvxJ", "sha256-cRlncoZ/tNV1cbKTR2cXiKEwVFkjvOz4kToIRLKBiSE=", "load", "load", 2], ["empty-good", "", "sha384-l+rtjjQWlmlLFL2PC8iIPwnXbiW45bZ6LU1ERjlAxYKUFswREZzY3pPyoT0RsvxJ", "load", "load", 2]]) {
for (const [name,preloadIntegrity,consumerIntegrity,preloadEvent,consumerEvent,requests] of [["good-empty", "sha384-l+rtjjQWlmlLFL2PC8iIPwnXbiW45bZ6LU1ERjlAxYKUFswREZzY3pPyoT0RsvxJ", "", "load", "load", 1], ["bad-empty", "sha384-AAAA", "", "error", "error", 1], ["bad-same", "sha384-AAAA", "sha384-AAAA", "error", "error", 1], ["bad-good", "sha384-AAAA", "sha384-l+rtjjQWlmlLFL2PC8iIPwnXbiW45bZ6LU1ERjlAxYKUFswREZzY3pPyoT0RsvxJ", "error", "load", 2], ["good-bad", "sha384-l+rtjjQWlmlLFL2PC8iIPwnXbiW45bZ6LU1ERjlAxYKUFswREZzY3pPyoT0RsvxJ", "sha384-AAAA", "load", "error", 2], ["good-options", "sha384-l+rtjjQWlmlLFL2PC8iIPwnXbiW45bZ6LU1ERjlAxYKUFswREZzY3pPyoT0RsvxJ?ignored=option", "sha384-l+rtjjQWlmlLFL2PC8iIPwnXbiW45bZ6LU1ERjlAxYKUFswREZzY3pPyoT0RsvxJ", "load", "load", 1], ["good-different-algorithm", "sha384-l+rtjjQWlmlLFL2PC8iIPwnXbiW45bZ6LU1ERjlAxYKUFswREZzY3pPyoT0RsvxJ", "sha256-cRlncoZ/tNV1cbKTR2cXiKEwVFkjvOz4kToIRLKBiSE=", "load", "load", 2], ["empty-good", "", "sha384-l+rtjjQWlmlLFL2PC8iIPwnXbiW45bZ6LU1ERjlAxYKUFswREZzY3pPyoT0RsvxJ", "load", "load", 2], ["malformed-empty", "sha384-AAAA===", "", "error", "error", 1], ["good-malformed", "sha384-l+rtjjQWlmlLFL2PC8iIPwnXbiW45bZ6LU1ERjlAxYKUFswREZzY3pPyoT0RsvxJ", "sha384-A=AAA", "load", "error", 2]]) {
const token='sri-'+rows.length,url=new URL('/probe-asset',location.href);
url.searchParams.set('token',token);url.searchParams.set('as','script');url.searchParams.set('cache',cache);
const link=document.createElement('link');Object.assign(link,{rel:'preload',as:'script',href:url.href,integrity:preloadIntegrity});
@@ -196,7 +196,7 @@ async fn document_preloads_supply_pending_and_completed_consumers_including_fail
.context("preload consumption results")?,
)?;
assert_eq!(value["basic"]["rows"].as_array().unwrap().len(), 24);
assert_eq!(value["integrity"]["rows"].as_array().unwrap().len(), 32);
assert_eq!(value["integrity"]["rows"].as_array().unwrap().len(), 40);
assert_eq!(value["basic"]["failures"], serde_json::json!([]));
assert_eq!(value["integrity"]["failures"], serde_json::json!([]));
assert_eq!(value["basic"]["executions"], 12);
@@ -58,7 +58,7 @@ async fn fetch_integrity_validates_local_bytes_in_window_and_worker() {
async fn fetch_integrity_validates_network_responses_in_window_and_worker() {
for worker in [false, true] {
let server =
StaticHttpServer::spawn_with_bodies(vec!["hello integrity".to_owned(); 8]).await;
StaticHttpServer::spawn_with_bodies(vec!["hello integrity".to_owned(); 16]).await;
let expression = format!(
r#"(async () => {{
const checks = [];
@@ -74,6 +74,14 @@ async fn fetch_integrity_validates_network_responses_in_window_and_worker() {
['whitespace', ' ', 'GET', true],
['no-integrity', '', 'GET', true],
['null-body', 'sha1-ignored', 'HEAD', false],
['excess-padding-mismatch', 'sha384-AAAA===', 'GET', false],
['interior-padding-mismatch', 'sha384-A=AAA', 'GET', false],
['padding-only-mismatch', 'sha384-====', 'GET', false],
['malformed-stronger-hash', valid + ' sha512-AAAA===', 'GET', false],
['excess-padding-match', valid + '====', 'GET', true],
['noncanonical-padding-bits', valid.replace('6A=', '6B='), 'GET', true],
['non-base64-ignored', 'sha384-***', 'GET', true],
['malformed-and-matching', 'sha256-A=AAA ' + valid, 'GET', true],
]) {{
try {{
const response = await fetch({} + label, {{integrity, method}});
@@ -85,7 +93,7 @@ async fn fetch_integrity_validates_network_responses_in_window_and_worker() {
}})()"#,
serde_json::to_string(server.base_url().as_str()).unwrap(),
);
run_integrity_probe(server.base_url().as_str(), worker, &expression, 13).await;
assert_eq!(server.finish().await.len(), 8);
run_integrity_probe(server.base_url().as_str(), worker, &expression, 25).await;
assert_eq!(server.finish().await.len(), 16);
}
}
@@ -175,6 +175,18 @@ mod tests {
);
}
}
assert!(!integrity_metadata_allows_preload_consumption(
&valid,
Some(malformed)
));
assert!(integrity_metadata_allows_preload_consumption(
malformed,
Some(malformed)
));
assert!(!integrity_metadata_allows_preload_consumption(
"",
Some(malformed)
));
}
}
@@ -183,6 +195,7 @@ mod tests {
let body = b"noncanonical integrity";
let digest = base64::engine::general_purpose::STANDARD
.encode(DigestAlgorithm::Sha256.digest_bytes(body));
let metadata = format!("sha256-{digest}");
for padding in ["", "=", "===", "======="] {
let padded = format!("sha256-{}{padding}", digest.trim_end_matches('='));
assert!(response_matches_subresource_integrity_metadata(
@@ -195,6 +208,10 @@ mod tests {
Some(&padded),
false
));
assert_eq!(
integrity_metadata_allows_preload_consumption(&metadata, Some(&padded)),
metadata == padded
);
}
// Only the top four bits of the final SHA-256 base64 symbol carry data.
let mut noncanonical = digest.trim_end_matches('=').as_bytes().to_vec();
@@ -207,6 +224,10 @@ mod tests {
Some(&noncanonical),
true
));
assert!(!integrity_metadata_allows_preload_consumption(
&metadata,
Some(&noncanonical)
));
}
#[test]