From 61c87dbe07e9414d2dfd5fc2ebf170b21453666f Mon Sep 17 00:00:00 2001 From: ldm0 Date: Fri, 18 Sep 2026 01:25:20 +0800 Subject: [PATCH] fix(fetch): enforce integrity before response handover Preserve integrity through Window and worker fetch continuations and wait for complete response bytes before applying the shared SRI validator. Cover local, network, intercepted, and Service Worker responses, retaining null-body state and abort reasons. Add regressions for digest selection, body completion, and null versus empty responses; preserve non-ASCII whitespace in integrity metadata parsing. --- .../context_host/resource_loading.rs | 4 + moli-renderer-v8/src/network_host.rs | 4 +- .../src/network_host/async_fetch.rs | 75 ++++++++- moli-renderer-v8/src/network_host/fetch.rs | 2 + .../src/network_host/fetch/bindings/paths.rs | 62 +++++-- .../fetch/bindings/service_worker.rs | 1 + .../src/network_host/fetch/integrity.rs | 26 +++ .../src/script_vm/subresource_fetch.rs | 45 +++++ .../src/script_vm/tests/browser_api/misc.rs | 10 +- .../src/script_vm/tests/dom_xhr/xhr.rs | 1 + .../src/script_vm/tests/fetch_integrity.rs | 91 ++++++++++ moli-renderer-v8/src/script_vm/tests/mod.rs | 3 + .../src/service_worker_runtime/events.rs | 1 + .../src/service_worker_runtime/service.rs | 2 + .../service/fetch_settlement.rs | 156 ++++++++++++++++++ moli-renderer-v8/src/subresource_integrity.rs | 5 +- moli-renderer-v8/src/types.rs | 7 + .../src/worker/global_scope/fetch.rs | 57 +++++-- .../src/worker/thread/dispatch.rs | 8 +- .../tests/fixtures/fetch-integrity.js | 104 ++++++++++++ 20 files changed, 621 insertions(+), 43 deletions(-) create mode 100644 moli-renderer-v8/src/network_host/fetch/integrity.rs create mode 100644 moli-renderer-v8/src/script_vm/tests/fetch_integrity.rs create mode 100644 moli-renderer-v8/tests/fixtures/fetch-integrity.js diff --git a/moli-renderer-v8/src/native_bridge/context_host/resource_loading.rs b/moli-renderer-v8/src/native_bridge/context_host/resource_loading.rs index fd57415c76..2c6a2c1cdd 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/resource_loading.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/resource_loading.rs @@ -551,6 +551,7 @@ impl JsContextHost { fetch_context: super::WindowFetchContext, resolver: v8::Global, keepalive: bool, + integrity: String, connect_policy: crate::document_runtime::DocumentConnectPolicySnapshot, csp_report_context: crate::network_host::WindowCspReportRequestContext, credentials_mode: moli_fetch::RequestCredentialsMode, @@ -594,6 +595,7 @@ impl JsContextHost { connect_policy, csp_report_context, redirect_mode, + integrity, ), ), deferred_request_started: false, @@ -837,6 +839,7 @@ impl JsContextHost { fetch_context: super::WindowFetchContext, resolver: v8::Global, keepalive: bool, + integrity: String, connect_policy: crate::document_runtime::DocumentConnectPolicySnapshot, csp_report_context: crate::network_host::WindowCspReportRequestContext, cancel_handle: Option, @@ -884,6 +887,7 @@ impl JsContextHost { connect_policy, csp_report_context, redirect_mode, + integrity, ), ), deferred_request_started: defer_request_started, diff --git a/moli-renderer-v8/src/network_host.rs b/moli-renderer-v8/src/network_host.rs index bf024ff259..23de2d85c1 100644 --- a/moli-renderer-v8/src/network_host.rs +++ b/moli-renderer-v8/src/network_host.rs @@ -84,7 +84,9 @@ pub(crate) use self::event_source::{ fail_event_source_connection, install_event_source_bindings, open_event_source_connection, update_event_source_stream_state, }; -pub(crate) use self::fetch::validate_no_cors_http_redirect_mode; +pub(crate) use self::fetch::{ + validate_fetch_response_integrity, validate_no_cors_http_redirect_mode, +}; pub(in crate::network_host) use self::fetch_surface::{ REQUEST_BODY_SLOT, REQUEST_BODY_USED_SLOT, REQUEST_CACHE_SLOT, REQUEST_CREDENTIALS_SLOT, REQUEST_DESTINATION_SLOT, REQUEST_DUPLEX_SLOT, REQUEST_HEADERS_SLOT, REQUEST_INTEGRITY_SLOT, diff --git a/moli-renderer-v8/src/network_host/async_fetch.rs b/moli-renderer-v8/src/network_host/async_fetch.rs index afd98eba2f..70df975d7c 100644 --- a/moli-renderer-v8/src/network_host/async_fetch.rs +++ b/moli-renderer-v8/src/network_host/async_fetch.rs @@ -737,7 +737,16 @@ pub(crate) fn spawn_async_subresource_fetch( }); return; } - if auth_requires_buffered_transport || !can_stream_subresource_body { + // Integrity applies before response handover, including metadata that + // the SRI parser later ignores. Never resolve fetch at the headers. + let integrity_requires_full_body = request + .subresource_request_metadata() + .and_then(|metadata| metadata.integrity.as_deref()) + .is_some_and(|integrity| !integrity.is_empty()); + if auth_requires_buffered_transport + || !can_stream_subresource_body + || integrity_requires_full_body + { let result = fetch_browser_subresource_with_preflight_headers_and_observer( loader, request, @@ -1777,6 +1786,70 @@ mod tests { Ok(()) } + #[tokio::test(flavor = "multi_thread")] + async fn fetch_integrity_waits_for_complete_network_body() -> Result<()> { + for truncated in [false, true] { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + read_http_request_text(&mut stream).await.unwrap(); + stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 15\r\nConnection: close\r\n\r\nhello", + ) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(25)).await; + if !truncated { + stream.write_all(b" integrity").await.unwrap(); + } + }); + let mut queue = RendererResourceCompletionTestHarness::new(); + let loader_owner = ResourceRequestClient::new(&FetchConfig::default())?; + let url = Url::parse(&format!("http://{addr}/integrity"))?; + let request = Request::get(url.as_str())? + .with_request_origin(moli_url::WebOrigin::from_url(&url)) + .with_browser_request_metadata(BrowserRequestMetadata::Fetch) + .with_script_fetch_metadata(moli_fetch::ScriptFetchRequestMetadata { + integrity: Some("sha1-ignored".to_owned()), + ..Default::default() + }); + spawn_async_subresource_fetch( + crate::network::RendererResourceTaskRunner::from_current_tokio()?, + queue.sender(), + loader_owner.handle(), + request, + Some(FetchCancelHandle::new()), + Vec::new(), + 42, + AsyncSubresourceNetworkContext { + frame_id: None, + request_origin: moli_url::WebOrigin::from_url(&url), + document_url: url.clone(), + resource_type: SubresourceResourceType::Fetch, + policy_context: Default::default(), + }, + url, + "GET".to_owned(), + Vec::new(), + None, + ); + match next_async_subresource_event(&mut queue).await? { + AsyncSubresourceFetchEvent::Completion(completion) => { + if truncated { + assert!(completion.result.is_err()); + } else { + assert_eq!(completion.result.unwrap().body_bytes(), b"hello integrity"); + } + } + other => anyhow::bail!("integrity fetch exposed an incomplete body: {other:?}"), + } + server.await?; + } + Ok(()) + } + #[tokio::test(flavor = "multi_thread")] async fn xhr_subresource_uses_streaming_events_until_body_finish() -> Result<()> { let listener = TcpListener::bind("127.0.0.1:0").await?; diff --git a/moli-renderer-v8/src/network_host/fetch.rs b/moli-renderer-v8/src/network_host/fetch.rs index 796374cc70..f7923994a2 100644 --- a/moli-renderer-v8/src/network_host/fetch.rs +++ b/moli-renderer-v8/src/network_host/fetch.rs @@ -1,11 +1,13 @@ mod bindings; mod input; +mod integrity; mod promise; use super::request::parse_fetch_init; use super::*; pub(crate) use self::bindings::window_fetch_callback; +pub(crate) use self::integrity::validate_fetch_response_integrity; /// Select the HTTP no-cors fetch path using the logical request URL, before /// Service Worker or DevTools interception. A DevTools transport URL rewrite diff --git a/moli-renderer-v8/src/network_host/fetch/bindings/paths.rs b/moli-renderer-v8/src/network_host/fetch/bindings/paths.rs index 6fd00a18e5..032819bf93 100644 --- a/moli-renderer-v8/src/network_host/fetch/bindings/paths.rs +++ b/moli-renderer-v8/src/network_host/fetch/bindings/paths.rs @@ -23,6 +23,7 @@ pub(super) fn record_intercepted_fetch( prepared.fetch_context, v8::Global::new(scope, resolver), prepared.keepalive, + prepared.integrity.clone(), prepared.connect_policy, prepared.csp_report_context, prepared.credentials_mode, @@ -153,24 +154,48 @@ pub(super) fn resolve_local_fetch( ) else { return Ok(None); }; - let response = result.map_err(|message| { - let message = if prepared.resolved_url.scheme() == "blob" && prepared.method == "GET" { - FILE_NOT_FOUND_ERROR_TEXT.to_owned() - } else { - message - }; - host.record_subresource_network(SubresourceNetworkRecord::failure( - prepared.frame_id.clone(), - prepared.document_url.clone(), - prepared.resolved_url.clone(), - prepared.method.clone(), - prepared.request_headers.clone(), - request_body_text(&prepared.body), - SubresourceResourceType::Fetch, - message.clone(), - )); - message - })?; + let response = result + .map_err(|message| { + if prepared.resolved_url.scheme() == "blob" && prepared.method == "GET" { + FILE_NOT_FOUND_ERROR_TEXT.to_owned() + } else { + message + } + }) + .and_then(|response| { + if !prepared.integrity.is_empty() { + let filter = FetchResponseRequest { + method: &prepared.method, + mode: prepared.request_mode, + redirect_mode: prepared.redirect_mode, + } + .network_response_filter( + &prepared.request_origin, + &response.head(), + prepared.credentials_mode, + ); + validate_fetch_response_integrity( + &prepared.integrity, + &prepared.method, + response.status, + &filter, + response.body_bytes(), + )?; + } + Ok(response) + }) + .inspect_err(|message| { + host.record_subresource_network(SubresourceNetworkRecord::failure( + prepared.frame_id.clone(), + prepared.document_url.clone(), + prepared.resolved_url.clone(), + prepared.method.clone(), + prepared.request_headers.clone(), + request_body_text(&prepared.body), + SubresourceResourceType::Fetch, + message.clone(), + )); + })?; host.record_subresource_network( SubresourceNetworkRecord::success_with_body( prepared.frame_id.clone(), @@ -260,6 +285,7 @@ pub(super) fn spawn_network_fetch( prepared.fetch_context, v8::Global::new(scope, resolver), prepared.keepalive, + prepared.integrity.clone(), prepared.connect_policy, prepared.csp_report_context, Some(cancel_handle.clone()), diff --git a/moli-renderer-v8/src/network_host/fetch/bindings/service_worker.rs b/moli-renderer-v8/src/network_host/fetch/bindings/service_worker.rs index 88ce13b7ba..1bd6ff8d65 100644 --- a/moli-renderer-v8/src/network_host/fetch/bindings/service_worker.rs +++ b/moli-renderer-v8/src/network_host/fetch/bindings/service_worker.rs @@ -58,6 +58,7 @@ pub(super) fn dispatch_service_worker_fetch( prepared.fetch_context.duplicate(scope), v8::Global::new(scope, resolver), prepared.keepalive, + prepared.integrity.clone(), prepared.connect_policy.clone(), prepared.csp_report_context.clone(), Some(cancel_handle.clone()), diff --git a/moli-renderer-v8/src/network_host/fetch/integrity.rs b/moli-renderer-v8/src/network_host/fetch/integrity.rs new file mode 100644 index 0000000000..c05e89b8b3 --- /dev/null +++ b/moli-renderer-v8/src/network_host/fetch/integrity.rs @@ -0,0 +1,26 @@ +use super::*; + +/// Main fetch checks the filtered response's body before applying SRI's +/// metadata parser. Even ignored metadata cannot authorize a null body. +pub(crate) fn validate_fetch_response_integrity( + integrity: &str, + method: &str, + status: u16, + filter: &crate::types::AsyncSubresourceFetchResponseFilter, + bytes: &[u8], +) -> Result<(), String> { + if integrity.is_empty() { + return Ok(()); + } + if !filter.is_readable() || response_has_null_body(method, status) { + return Err("fetch: integrity metadata requires a non-null response body".to_owned()); + } + if !crate::subresource_integrity::response_matches_subresource_integrity_metadata( + bytes, + Some(integrity), + true, + ) { + return Err("fetch: response body does not match its integrity metadata".to_owned()); + } + Ok(()) +} diff --git a/moli-renderer-v8/src/script_vm/subresource_fetch.rs b/moli-renderer-v8/src/script_vm/subresource_fetch.rs index 08d18e72c3..741e671ecf 100644 --- a/moli-renderer-v8/src/script_vm/subresource_fetch.rs +++ b/moli-renderer-v8/src/script_vm/subresource_fetch.rs @@ -35,6 +35,40 @@ use crate::types::{ }; use crate::util::v8_string; +fn validate_pending_window_fetch_integrity( + pending: &PendingSubresourceFetchState, + method: &str, + response: &crate::protocol_types::NavigationResponse, + response_filter: Option<&AsyncSubresourceFetchResponseFilter>, +) -> std::result::Result<(), String> { + let Some(fetch) = pending + .continuation + .window_fetch() + .filter(|fetch| !fetch.integrity().is_empty()) + else { + return Ok(()); + }; + let filter = response_filter.cloned().unwrap_or_else(|| { + crate::network_host::FetchResponseRequest { + method, + mode: pending.request_mode, + redirect_mode: fetch.redirect_mode(), + } + .network_response_filter( + &pending.request_origin, + &response.head(), + pending.credentials_mode, + ) + }); + crate::network_host::validate_fetch_response_integrity( + fetch.integrity(), + method, + response.status, + &filter, + response.body_bytes(), + ) +} + #[derive(Clone, Copy)] enum WorkerOwnedFetchTarget { Dedicated { @@ -3237,6 +3271,7 @@ impl ScriptVm { request_body: Option, response_status_text: Option, skip_fetch_security_validation: bool, + response_filter: Option, network_error_text: Option, result: std::result::Result, ) -> Result<()> { @@ -3270,6 +3305,12 @@ impl ScriptVm { pending.policy_context, )?; } + validate_pending_window_fetch_integrity( + &pending, + &request_method, + &response, + response_filter.as_ref(), + )?; Ok(response) }) } else { @@ -3664,6 +3705,7 @@ impl ScriptVm { request_body, response_status_text, skip_fetch_security_validation, + response_filter, network_error_text, result, ) @@ -3780,6 +3822,9 @@ impl ScriptVm { Err(violation) => return Err(violation.into_message()), } } + validate_pending_window_fetch_integrity( + &pending, &request_method, &response, response_filter.as_ref(), + )?; Ok(response) }); trace_async_subresource_stage( diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/misc.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/misc.rs index 4e74971368..53c5311d97 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/misc.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/misc.rs @@ -14435,7 +14435,8 @@ async fn navigator_service_worker_fetch_event_request_preserves_window_fetch_pol cache: "reload", referrer: "./referrer.html", referrerPolicy: "origin", - integrity: "sha256-test", + // Ignore this algorithm when hashing the response that reflects it. + integrity: "sha1-test", keepalive: true, priority: "low" }); @@ -14456,7 +14457,7 @@ async fn navigator_service_worker_fetch_event_request_preserves_window_fetch_pol &loader, "String(globalThis.__serviceWorkerFetchRequestPolicyMetadataProbe)", &format!( - "200|cache=reload|referrer={base_url}/app/referrer.html|referrerPolicy=origin|integrity=sha256-test|keepalive=true" + "200|cache=reload|referrer={base_url}/app/referrer.html|referrerPolicy=origin|integrity=sha1-test|keepalive=true" ), ) .await; @@ -14705,7 +14706,8 @@ async fn navigator_service_worker_fetch_event_request_preserves_worker_fetch_pol cache: "reload", referrer: "./worker-referrer.html", referrerPolicy: "origin", - integrity: "sha256-test", + // Ignore this algorithm when hashing the response that reflects it. + integrity: "sha1-test", keepalive: true, priority: "high" }); @@ -14758,7 +14760,7 @@ async fn navigator_service_worker_fetch_event_request_preserves_worker_fetch_pol &loader, "String(globalThis.__serviceWorkerWorkerFetchRequestPolicyMetadataProbe)", &format!( - "200|basic|from-worker|cache=reload|referrer={base_url}/app/worker-referrer.html|referrerPolicy=origin|integrity=sha256-test|keepalive=true" + "200|basic|from-worker|cache=reload|referrer={base_url}/app/worker-referrer.html|referrerPolicy=origin|integrity=sha1-test|keepalive=true" ), ) .await; diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/xhr.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/xhr.rs index bd6f22b94a..97a34fa152 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/xhr.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/xhr.rs @@ -222,6 +222,7 @@ fn pending_fetch_continuation<'s>( ) .expect("test Fetch should capture its CSP report context"), moli_fetch::RequestRedirectMode::Follow, + String::new(), ), ) } diff --git a/moli-renderer-v8/src/script_vm/tests/fetch_integrity.rs b/moli-renderer-v8/src/script_vm/tests/fetch_integrity.rs new file mode 100644 index 0000000000..c6ca60f6e9 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/fetch_integrity.rs @@ -0,0 +1,91 @@ +use super::*; + +async fn run_integrity_probe( + page_url: &str, + worker: bool, + expression: &str, + expected_checks: usize, +) { + let loader = static_http_loader([]); + let mut vm = new_page_task_executor_test_vm_with_loader(page_url, &loader); + vm.eval("globalThis.integrityResult = null;").unwrap(); + let script = if worker { + let source = format!( + "{expression}.then(postMessage, error => postMessage({{error:String(error.stack || error)}}));" + ); + format!( + "const workerUrl = URL.createObjectURL(new Blob([{}], {{type:'text/javascript'}})); const worker = new Worker(workerUrl); worker.onmessage = event => {{ integrityResult = event.data; worker.terminate(); URL.revokeObjectURL(workerUrl); }}; worker.onerror = event => {{ integrityResult = {{error:event.message}}; event.preventDefault(); }};", + serde_json::to_string(&source).unwrap() + ) + } else { + format!( + "{expression}.then(value => {{ integrityResult = value; }}, error => {{ integrityResult = {{error:String(error.stack || error)}}; }});" + ) + }; + vm.eval(&script).unwrap(); + advance_page_task_executor_until_eval_equals( + &mut vm, + &loader, + "String(integrityResult !== null)", + "true", + "Fetch integrity probe should finish", + ) + .await; + let result: serde_json::Value = + serde_json::from_str(&vm.eval("JSON.stringify(integrityResult)").unwrap()).unwrap(); + let checks = result["checks"] + .as_array() + .unwrap_or_else(|| panic!("worker={worker}: {result}")); + let failures: Vec<_> = checks + .iter() + .filter(|check| check["pass"] != true) + .collect(); + assert_eq!(result["state"], "pass", "worker={worker}: {failures:?}"); + assert_eq!(checks.len(), expected_checks, "worker={worker}"); +} + +#[tokio::test(flavor = "current_thread")] +async fn fetch_integrity_validates_local_bytes_in_window_and_worker() { + let fixture = include_str!("../../../tests/fixtures/fetch-integrity.js"); + let expression = + format!("(async () => {{ {fixture}\nreturn fetchIntegrityProbe('', true); }})()"); + for worker in [false, true] { + run_integrity_probe("https://fetch-integrity.test/", worker, &expression, 296).await; + } +} + +#[tokio::test(flavor = "current_thread")] +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; + let expression = format!( + r#"(async () => {{ + const checks = []; + const check = (label, actual, wanted) => checks.push({{label, actual, wanted, pass:actual === wanted}}); + const valid = 'sha256-9pyxsrnsacWVDvpoeZHDpEDkdnPx2ySEsclLyHWyL6A='; + const wrong = 'sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='; + for (const [label, integrity, method, accepted] of [ + ['valid', valid, 'GET', true], + ['invalid', wrong, 'GET', false], + ['stronger-invalid', valid + ' sha512-AA==', 'GET', false], + ['same-level-valid', wrong + ' ' + valid, 'GET', true], + ['ignored', 'sha1-ignored', 'GET', true], + ['whitespace', ' ', 'GET', true], + ['no-integrity', '', 'GET', true], + ['null-body', 'sha1-ignored', 'HEAD', false], + ]) {{ + try {{ + const response = await fetch({} + label, {{integrity, method}}); + check(label + '/accepted', true, accepted); + if (accepted) check(label + '/body', await response.text(), 'hello integrity'); + }} catch (error) {{ check(label + '/accepted', error instanceof TypeError ? false : String(error), accepted); }} + }} + return {{state:checks.every(check => check.pass) ? 'pass' : 'fail', checks}}; + }})()"#, + 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); + } +} diff --git a/moli-renderer-v8/src/script_vm/tests/mod.rs b/moli-renderer-v8/src/script_vm/tests/mod.rs index 95f04fd73a..bdbf6b4e3f 100644 --- a/moli-renderer-v8/src/script_vm/tests/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/mod.rs @@ -304,6 +304,7 @@ fn register_pending_window_fetch_for_test( fetch_context, v8::Global::new(scope, resolver), keepalive, + String::new(), connect_policy, csp_report_context, Some(cancel_handle.clone()), @@ -469,6 +470,7 @@ fn register_pending_window_fetch_with_connect_policy_for_test( fetch_context, v8::Global::new(scope, resolver), keepalive, + String::new(), crate::document_runtime::DocumentConnectPolicySnapshot::from_policy_container(&policy), csp_report_context, Some(cancel_handle.clone()), @@ -15826,6 +15828,7 @@ mod canvas_webgl; mod close_watchers; mod dom_elements; mod dom_xhr; +mod fetch_integrity; mod fetch_referrer; mod fetch_request_guard; mod http_fixture; diff --git a/moli-renderer-v8/src/service_worker_runtime/events.rs b/moli-renderer-v8/src/service_worker_runtime/events.rs index 08e2d958f9..0eb9a34632 100644 --- a/moli-renderer-v8/src/service_worker_runtime/events.rs +++ b/moli-renderer-v8/src/service_worker_runtime/events.rs @@ -636,6 +636,7 @@ pub(crate) struct ServiceWorkerFetchResponse { pub(crate) status: u16, pub(crate) status_text: String, pub(crate) headers: Vec<(String, String)>, + pub(crate) body_is_null: bool, pub(crate) body: Vec, } diff --git a/moli-renderer-v8/src/service_worker_runtime/service.rs b/moli-renderer-v8/src/service_worker_runtime/service.rs index 9e8c09d4cf..7127b6d362 100644 --- a/moli-renderer-v8/src/service_worker_runtime/service.rs +++ b/moli-renderer-v8/src/service_worker_runtime/service.rs @@ -8965,6 +8965,7 @@ self.addEventListener("message", event => { event_id, owner: test_run_owner(version_id, &run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + body_is_null: false, cors_exposed_header_names: None, status: 200, status_text: "OK".to_owned(), @@ -9267,6 +9268,7 @@ self.addEventListener("message", event => { event_id, owner: test_run_owner(version_id, &run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + body_is_null: false, cors_exposed_header_names: None, status: 200, status_text: "OK".to_owned(), diff --git a/moli-renderer-v8/src/service_worker_runtime/service/fetch_settlement.rs b/moli-renderer-v8/src/service_worker_runtime/service/fetch_settlement.rs index d5c5af1f60..4a95a47c1b 100644 --- a/moli-renderer-v8/src/service_worker_runtime/service/fetch_settlement.rs +++ b/moli-renderer-v8/src/service_worker_runtime/service/fetch_settlement.rs @@ -136,6 +136,7 @@ fn service_worker_fetch_can_forward_stream( &response_head.headers, )?; Ok(job.direct_completion_tx.is_none() + && job.metadata.integrity.is_empty() && !matches!( job.network_context.resource_type, crate::types::SubresourceResourceType::Audio @@ -617,6 +618,13 @@ impl ServiceWorkerRuntimeService { self.complete_fetch_with_failure(job, message); return; } + if response.body_is_null && !job.metadata.integrity.is_empty() { + self.complete_fetch_with_failure( + job, + "fetch: integrity metadata requires a non-null response body".to_owned(), + ); + return; + } let response_filter = service_worker_fetch_response_filter(&response, job.request.redirect_mode); let navigation_response = crate::protocol_types::NavigationResponse::from_head_and_body( @@ -1254,6 +1262,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + body_is_null: false, cors_exposed_header_names: None, final_url: Some(final_url.clone()), response_type: "default".to_owned(), @@ -1288,6 +1297,122 @@ mod tests { assert_eq!(response.body_text(), "service-worker-body"); } + #[test] + fn fetch_integrity_rejects_synthetic_null_bodies_but_preserves_empty_bodies() { + for integrity in ["", "sha1-ignored", " "] { + for body_is_null in [false, true] { + let service = new_service_worker_runtime_service(); + let event_id = ServiceWorkerEventId(21); + let version_id = ServiceWorkerVersionId(1); + let run = RendererServiceWorkerRunIdentity::fresh(); + let mut queue = + crate::page_task_queue::RendererResourceCompletionTestHarness::new(); + let final_url = insert_active_fetch_job( + &service, + event_id, + version_id, + &run, + 301, + queue.sender(), + ); + service + .inner + .state + .lock() + .pending_fetch_jobs + .get_mut(&event_id) + .unwrap() + .metadata + .integrity = integrity.to_owned(); + service.finish_fetch_event_completed(ServiceWorkerFetchCompletion { + event_id, + owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new( + version_id, run, + ), + result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + body_is_null, + cors_exposed_header_names: None, + final_url: Some(final_url), + response_type: "default".to_owned(), + redirected: false, + status: 200, + status_text: "OK".to_owned(), + headers: Vec::new(), + body: Vec::new(), + }), + }); + let completion = pop_async_subresource_completion(&mut queue); + assert_eq!( + completion.result.is_ok(), + integrity.is_empty() || !body_is_null, + "integrity={integrity:?}, body_is_null={body_is_null}" + ); + } + } + } + + #[test] + fn fetch_integrity_defers_service_worker_stream_handover() { + let service = new_service_worker_runtime_service(); + let event_id = ServiceWorkerEventId(21); + let version_id = ServiceWorkerVersionId(1); + let run = RendererServiceWorkerRunIdentity::fresh(); + let mut queue = crate::page_task_queue::RendererResourceCompletionTestHarness::new(); + let final_url = + insert_active_fetch_job(&service, event_id, version_id, &run, 301, queue.sender()); + service + .inner + .state + .lock() + .pending_fetch_jobs + .get_mut(&event_id) + .unwrap() + .metadata + .integrity = "sha1-ignored".to_owned(); + service.finish_fetch_stream_started(ServiceWorkerFetchStreamStarted { + event_id, + owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new( + version_id, + run.clone(), + ), + body_source_id: 77, + response_head: MaterializedServiceWorkerFetchResponseHead { + cors_exposed_header_names: None, + final_url: Some(final_url.clone()), + response_type: "default".to_owned(), + redirected: false, + status: 200, + status_text: "OK".to_owned(), + headers: Vec::new(), + }, + }); + assert!(queue.pop_next_async_subresource_event().is_none()); + service.finish_fetch_stream_chunk(ServiceWorkerFetchStreamChunk { + event_id, + body_source_id: 77, + bytes: b"hello".to_vec(), + }); + assert!(queue.pop_next_async_subresource_event().is_none()); + service.finish_fetch_event_completed(ServiceWorkerFetchCompletion { + event_id, + owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), + result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + body_is_null: false, + cors_exposed_header_names: None, + final_url: Some(final_url), + response_type: "default".to_owned(), + redirected: false, + status: 200, + status_text: "OK".to_owned(), + headers: Vec::new(), + body: b"hello integrity".to_vec(), + }), + }); + let completion = pop_async_subresource_completion(&mut queue); + assert_eq!(completion.result.unwrap().body_bytes(), b"hello integrity"); + assert!(queue.pop_next_async_subresource_event().is_none()); + } + #[test] fn xhr_upload_listener_preflight_survives_service_worker_network_fallback() { let service = new_service_worker_runtime_service(); @@ -1397,6 +1522,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + body_is_null: false, cors_exposed_header_names: None, final_url: Some(final_url), response_type: "default".to_owned(), @@ -1523,6 +1649,7 @@ mod tests { version_id, run, ), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + body_is_null: false, cors_exposed_header_names: None, final_url: None, response_type: "default".to_owned(), @@ -1588,6 +1715,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + body_is_null: false, cors_exposed_header_names: None, final_url: None, response_type: "opaque".to_owned(), @@ -1634,6 +1762,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + body_is_null: false, cors_exposed_header_names: None, final_url: None, response_type: "opaqueredirect".to_owned(), @@ -1679,6 +1808,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + body_is_null: false, cors_exposed_header_names: None, final_url: Some(final_url.clone()), response_type: "default".to_owned(), @@ -1723,6 +1853,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + body_is_null: false, cors_exposed_header_names: None, final_url: Some(url("https://example.test/app/manual-final.txt")), response_type: "default".to_owned(), @@ -1772,6 +1903,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + body_is_null: false, cors_exposed_header_names: None, final_url: None, response_type: "default".to_owned(), @@ -1896,6 +2028,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + body_is_null: false, cors_exposed_header_names: None, final_url: None, response_type: "default".to_owned(), @@ -1958,6 +2091,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + body_is_null: false, cors_exposed_header_names: None, final_url: None, response_type: "default".to_owned(), @@ -2041,6 +2175,7 @@ mod tests { } })); let redirect = || ServiceWorkerFetchResponse { + body_is_null: false, cors_exposed_header_names: None, final_url: None, response_type: "default".to_owned(), @@ -2126,6 +2261,7 @@ mod tests { .without_inferred_referrer() .with_fetch_priority_hint(Some(moli_fetch::FetchPriorityHint::High)); let response = ServiceWorkerFetchResponse { + body_is_null: false, cors_exposed_header_names: None, final_url: Some(request_url.clone()), response_type: "default".to_owned(), @@ -2201,6 +2337,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + body_is_null: false, cors_exposed_header_names: None, final_url: None, response_type: "default".to_owned(), @@ -2249,6 +2386,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + body_is_null: false, cors_exposed_header_names: None, final_url: Some(response_url.clone()), response_type: "default".to_owned(), @@ -2317,6 +2455,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + body_is_null: false, cors_exposed_header_names: None, final_url: None, response_type: "error".to_owned(), @@ -2365,6 +2504,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + body_is_null: false, cors_exposed_header_names: None, final_url: Some(url("https://cross-origin.test/data.txt")), response_type: "cors".to_owned(), @@ -2412,6 +2552,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + body_is_null: false, cors_exposed_header_names: None, final_url: None, response_type: "opaque".to_owned(), @@ -2475,6 +2616,7 @@ mod tests { run.clone(), ), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + body_is_null: false, cors_exposed_header_names: None, final_url: None, response_type: "opaque".to_owned(), @@ -2523,6 +2665,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + body_is_null: false, cors_exposed_header_names: None, final_url: Some(url("https://cdn.example.test/app/image.png")), response_type: "default".to_owned(), @@ -2577,6 +2720,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + body_is_null: false, cors_exposed_header_names: None, final_url: Some(url("https://cdn.example.test/app/image.png")), response_type: "default".to_owned(), @@ -2641,6 +2785,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + body_is_null: false, cors_exposed_header_names: None, final_url: Some(url("https://cdn.example.test/app/pixel.png")), response_type: "default".to_owned(), @@ -2701,6 +2846,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + body_is_null: false, cors_exposed_header_names: None, final_url: Some(url("https://cdn.example.test/app/pixel.png")), response_type: "default".to_owned(), @@ -2760,6 +2906,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + body_is_null: false, cors_exposed_header_names: None, final_url: Some(url("https://cdn.example.test/app/pixel.png")), response_type: "default".to_owned(), @@ -2819,6 +2966,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + body_is_null: false, cors_exposed_header_names: None, final_url: Some(url("https://cdn.example.test/app/pixel.png")), response_type: "default".to_owned(), @@ -2879,6 +3027,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + body_is_null: false, cors_exposed_header_names: None, final_url: Some(url("https://cdn.example.test/app/pixel.png")), response_type: "default".to_owned(), @@ -2933,6 +3082,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + body_is_null: false, cors_exposed_header_names: None, final_url: Some(url("https://cdn.example.test/app/pixel.png")), response_type: "default".to_owned(), @@ -2992,6 +3142,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + body_is_null: false, cors_exposed_header_names: None, final_url: Some(url("https://cdn.example.test/app/pixel.png")), response_type: "default".to_owned(), @@ -3050,6 +3201,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + body_is_null: false, cors_exposed_header_names: None, final_url: Some(url("https://cdn.example.test/app/pixel.png")), response_type: "default".to_owned(), @@ -3102,6 +3254,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + body_is_null: false, cors_exposed_header_names: None, final_url: Some(url("https://cdn.example.test/app/pixel.png")), response_type: "default".to_owned(), @@ -3349,6 +3502,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + body_is_null: false, cors_exposed_header_names: None, final_url: None, response_type: "default".to_owned(), @@ -3398,6 +3552,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + body_is_null: false, cors_exposed_header_names: None, final_url: Some(url("https://cdn.example.test/app/data.json")), response_type: "default".to_owned(), @@ -3446,6 +3601,7 @@ mod tests { event_id, owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run), result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse { + body_is_null: false, cors_exposed_header_names: None, final_url: None, response_type: "opaqueredirect".to_owned(), diff --git a/moli-renderer-v8/src/subresource_integrity.rs b/moli-renderer-v8/src/subresource_integrity.rs index cf3ea8dfbd..83db91cd26 100644 --- a/moli-renderer-v8/src/subresource_integrity.rs +++ b/moli-renderer-v8/src/subresource_integrity.rs @@ -23,10 +23,7 @@ pub(crate) fn response_matches_subresource_integrity_metadata( integrity: Option<&str>, response_is_eligible: bool, ) -> bool { - let Some(integrity) = integrity - .map(str::trim) - .filter(|integrity| !integrity.is_empty()) - else { + let Some(integrity) = integrity.filter(|integrity| !integrity.is_empty()) else { return true; }; let metadata = parse_integrity_metadata(integrity); diff --git a/moli-renderer-v8/src/types.rs b/moli-renderer-v8/src/types.rs index 67892e8f60..a4b6622f8a 100644 --- a/moli-renderer-v8/src/types.rs +++ b/moli-renderer-v8/src/types.rs @@ -247,6 +247,7 @@ pub(super) struct PendingWindowFetchContinuation { csp_report_context: crate::network_host::WindowCspReportRequestContext, redirect_csp_state: crate::network_host::FetchCspRedirectState, redirect_mode: moli_fetch::RequestRedirectMode, + integrity: String, } enum PendingWindowFetchPromise { @@ -261,6 +262,7 @@ impl PendingWindowFetchContinuation { connect_policy: crate::document_runtime::DocumentConnectPolicySnapshot, csp_report_context: crate::network_host::WindowCspReportRequestContext, redirect_mode: moli_fetch::RequestRedirectMode, + integrity: String, ) -> Self { Self { promise: PendingWindowFetchPromise::Active(resolver), @@ -269,6 +271,7 @@ impl PendingWindowFetchContinuation { connect_policy, csp_report_context, redirect_mode, + integrity, } } @@ -317,6 +320,10 @@ impl PendingWindowFetchContinuation { pub(super) fn redirect_mode(&self) -> moli_fetch::RequestRedirectMode { self.redirect_mode } + + pub(super) fn integrity(&self) -> &str { + &self.integrity + } } #[derive(Clone, Debug, Eq, Hash, PartialEq)] diff --git a/moli-renderer-v8/src/worker/global_scope/fetch.rs b/moli-renderer-v8/src/worker/global_scope/fetch.rs index 9b9fdaec4d..69359dd775 100644 --- a/moli-renderer-v8/src/worker/global_scope/fetch.rs +++ b/moli-renderer-v8/src/worker/global_scope/fetch.rs @@ -257,7 +257,7 @@ pub(in crate::worker) fn spawn_worker_fetch_network( } Err(error) => (Err(format!("fetch: {error}")), None), } - } else if !allow_headers_first { + } else if !allow_headers_first || !request_metadata.integrity.is_empty() { // Worker fetch still resolves after the full response, but ordinary // transfers can keep their body in the same chunked/pooled carrier that // CDP Network capture uses instead of forcing a single buffered Response. @@ -3197,6 +3197,49 @@ pub(in crate::worker) fn drain_worker_fetch_completion_result( return; } }; + let response_request = crate::network_host::FetchResponseRequest { + method: &pending.request_method, + mode: pending.request_mode, + redirect_mode: pending.redirect_mode, + }; + let response_filter = completion.response_filter.or_else(|| { + Some(response_request.network_response_filter( + &request_origin, + &response_head, + pending.credentials_mode, + )) + }); + if !pending.request_metadata.integrity.is_empty() { + let body = match &response { + WorkerFetchResponse::Materialized(response) => { + Ok(std::borrow::Cow::Borrowed(response.body_bytes())) + } + WorkerFetchResponse::Streamed { body, .. } => { + body.try_bytes().map_err(|error| { + format!("fetch: failed to read integrity response body: {error}") + }) + } + }; + let validation = body.and_then(|bytes| { + crate::network_host::validate_fetch_response_integrity( + &pending.request_metadata.integrity, + &pending.request_method, + response_head.status, + response_filter + .as_ref() + .expect("Fetch response filter was selected"), + &bytes, + ) + }); + if let Err(message) = validation { + record_worker_fetch_failure(&state.borrow(), &pending, message.clone()); + let reason = v8_string(scope, &message) + .map(|message| v8::Exception::type_error(scope, message)) + .unwrap_or_else(|| v8::undefined(scope).into()); + let _ = resolver.reject(scope, reason); + return; + } + } if opaque_response_blocked { record_worker_fetch_failure( &state.borrow(), @@ -3225,18 +3268,6 @@ pub(in crate::worker) fn drain_worker_fetch_completion_result( }, )); } - let response_request = crate::network_host::FetchResponseRequest { - method: &pending.request_method, - mode: pending.request_mode, - redirect_mode: pending.redirect_mode, - }; - let response_filter = completion.response_filter.or_else(|| { - Some(response_request.network_response_filter( - &request_origin, - &response_head, - pending.credentials_mode, - )) - }); let response_obj = match response.into_fetch_parts() { WorkerFetchResponseParts::Materialized { head, body } => { let body = if opaque_response_blocked { diff --git a/moli-renderer-v8/src/worker/thread/dispatch.rs b/moli-renderer-v8/src/worker/thread/dispatch.rs index 3e5a6f810d..7906a66ee7 100644 --- a/moli-renderer-v8/src/worker/thread/dispatch.rs +++ b/moli-renderer-v8/src/worker/thread/dispatch.rs @@ -3070,6 +3070,8 @@ fn service_worker_respond_with_settled<'s>( ); return; } + let body_is_null = + crate::network_host::body_stream_object(scope, response).is_none(); let body_source_id = new_network_body_source_id(); let (body, stream_cancel_handle) = build_service_worker_respond_with_stream_chunk_callback( @@ -3097,7 +3099,7 @@ fn service_worker_respond_with_settled<'s>( }); match body { MaterializedResponseBody::Ready(body) => ServiceWorkerFetchResult::Response( - service_worker_fetch_response_from_materialized(head, body), + service_worker_fetch_response_from_materialized(head, body, body_is_null), ), MaterializedResponseBody::Pending(promise) => { let stream_body = stream_cancel_handle.is_some(); @@ -3231,9 +3233,11 @@ fn service_worker_respond_with_lifetime_settled_for_event( fn service_worker_fetch_response_from_materialized( head: MaterializedResponseHead, body: Vec, + body_is_null: bool, ) -> ServiceWorkerFetchResponse { let response = head.with_body(body); ServiceWorkerFetchResponse { + body_is_null, cors_exposed_header_names: response.cors_exposed_header_names, final_url: response.final_url, response_type: response.response_type, @@ -3750,7 +3754,7 @@ fn service_worker_respond_with_body_settled_for_event( pending.pending_respond_with_stream_body_source_id = None; pending.pending_respond_with_stream_cancel_handle = None; ServiceWorkerFetchResult::Response(service_worker_fetch_response_from_materialized( - head, body, + head, body, false, )) } Err(error) => { diff --git a/moli-renderer-v8/tests/fixtures/fetch-integrity.js b/moli-renderer-v8/tests/fixtures/fetch-integrity.js new file mode 100644 index 0000000000..ed84b00185 --- /dev/null +++ b/moli-renderer-v8/tests/fixtures/fetch-integrity.js @@ -0,0 +1,104 @@ +async function fetchIntegrityProbe(baseUrl, localOnly = false) { + const resources = [{"name": "text", "bytes": [104, 101, 108, 108, 111, 32, 105, 110, 116, 101, 103, 114, 105, 116, 121], "base64": "aGVsbG8gaW50ZWdyaXR5", "sha256": "sha256-9pyxsrnsacWVDvpoeZHDpEDkdnPx2ySEsclLyHWyL6A=", "sha384": "sha384-CCi8AQnAx6lR9HsMyFzQjILv5ia8wub93wfmdoOS4L7a++MzT++Fu7fSP7kYTmWF", "sha512": "sha512-mXVi95TF+mUwM+gHNM32LCn1SWYf95zOfK1yAG1GIuN0/bZwRhGvOtN/7CHf7whlbimiebfCZwlROrdR5NgQqw=="}, {"name": "empty", "bytes": [], "base64": "", "sha256": "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=", "sha384": "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb", "sha512": "sha512-z4PhNX7vuL3xVChQ1m2AB9Yg5AULVxXcg/SpIdNs6c5H0NE8XYXysP+DGNKHfuwvY7kxvUdBeoGlODJ6+SfaPg=="}, {"name": "binary", "bytes": [0, 255, 128, 1, 2, 65], "base64": "AP+AAQJB", "sha256": "sha256-AUPFNQOnS+WsR2eYg0rb6qoEp35s40QZHzqIjaQb3Cg=", "sha384": "sha384-CTICUISiEC8kw5GcbXJIzxYr8ks0/3gSUBfe8Hg0YLLHkBLQpuiU9J7fDDm3IzDj", "sha512": "sha512-k41a7eyRLajjPtfRMt7ek2wfdA4Zo0wCQbBe/DpXCRMLP9oF8gTJutkQXJOsViyCOtMxhbex8hVEO+IgHdJpHA=="}, {"name": "unicode", "bytes": [240, 157, 140, 134, 195, 169, 239, 191, 189], "base64": "8J2MhsOp77+9", "sha256": "sha256-gwOIYBj9iyRwb5o4SO40WH4GhpQVcsyVoSPCr+AcL+k=", "sha384": "sha384-AlLyVKhsauH8U6ot4I3tmF5IQlBxrn9XKoU83Xk/rYZQlXXqTLA1oW+STAuA0Wbw", "sha512": "sha512-7NrPqei7Xlo2hwgwyz1/Nebsgd7yqgP4Q8DCwXLRl5rpuhvy2YE/7k6g57s8Zp2s9fg1WAbvVmDk0Q8FEfLDhQ=="}]; + + const checks = []; + const check = (label, actual, wanted) => checks.push({label, actual, wanted, + pass: JSON.stringify(actual) === JSON.stringify(wanted)}); + const wrong256 = 'sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='; + const wrong512 = 'sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=='; + async function attempt(label, input, init, wanted, expectedBytes, expectedType) { + try { + const response = await fetch(input, init); + check(label + '/accepted', true, wanted); + if (wanted) { + check(label + '/bytes', Array.from(new Uint8Array(await response.arrayBuffer())), expectedBytes); + if (expectedType) check(label + '/type', response.type, expectedType); + } else if (response.body) { await response.body.cancel(); } + } catch (error) { check(label + '/accepted', error instanceof TypeError ? false : String(error), wanted); } + } + for (const data of resources) { + const metadata = [ + ['empty', '', true], ['sha256', data.sha256, true], ['sha384', data.sha384, true], + ['sha512', data.sha512, true], ['unpadded', data.sha512.replace(/=+$/, ''), true], + ['base64url', data.sha512.replace(/\+/g, '-').replace(/\//g, '_'), true], + ['invalid', wrong256, false], ['stronger-invalid', data.sha256 + ' ' + wrong512, false], + ['stronger-valid', wrong256 + ' ' + data.sha384, true], + ['same-level-valid', wrong512 + ' ' + data.sha512, true], + ['both-invalid', wrong256 + ' ' + wrong512, false], + ['unsupported', 'sha1-ignored', true], ['invalid-syntax', 'sha256-***', true], + ['whitespace', ' \t\n', true], ['leading-trailing', '\t' + data.sha256 + '\n', true], + ['unicode-prefix', '\u00a0' + wrong256, true], ['unicode-suffix', wrong256 + '\u00a0', true], + ]; + const blobUrl = URL.createObjectURL(new Blob([new Uint8Array(data.bytes)])); + try { + const urls = [['data', 'data:application/octet-stream;base64,' + data.base64], ['blob', blobUrl]]; + if (!localOnly) urls.push(['network', baseUrl + '/body?body=' + data.name]); + for (const [source, url] of urls) { + for (const [kind, integrity, wanted] of metadata) { + await attempt(data.name + '/' + source + '/' + kind, url, {integrity}, wanted, data.bytes); + } + const original = new Request(url, {integrity: wrong256}); + await attempt(data.name + '/' + source + '/request', original, undefined, false, []); + await attempt(data.name + '/' + source + '/clone', original.clone(), undefined, false, []); + await attempt(data.name + '/' + source + '/override', original, {integrity: data.sha256}, true, data.bytes); + await attempt(data.name + '/' + source + '/clear', original, {integrity: ''}, true, data.bytes); + } + } finally { URL.revokeObjectURL(blobUrl); } + } + if (!localOnly) { + const text = resources[0]; + const cross = baseUrl.replace('127.0.0.1', 'localhost'); + await attempt('cors-valid', cross + '/body?body=text&cors=1', {integrity: text.sha256}, true, text.bytes, 'cors'); + await attempt('cors-invalid', cross + '/body?body=text&cors=1', {integrity: wrong256}, false, []); + await attempt('cors-denied', cross + '/body?body=text', {integrity: text.sha256}, false, []); + for (const integrity of ['', text.sha256, 'sha1-ignored', ' ', 'sha256-***']) { + await attempt('opaque/' + integrity, cross + '/body?body=text', {integrity, mode: 'no-cors'}, !integrity, [], 'opaque'); + } + for (const [label, method, status] of [['head', 'HEAD', 200], ['204', 'GET', 204], ['205', 'GET', 205], ['304', 'GET', 304]]) { + for (const [kind, integrity] of [['empty', ''], ['valid-empty', resources[1].sha256], ['unsupported', 'sha1-ignored'], ['whitespace', ' ']]) { + await attempt('null/' + label + '/' + kind, baseUrl + '/body?body=empty&status=' + status, {method, integrity}, !integrity, []); + } + } + await attempt('http-error-valid', baseUrl + '/body?body=text&status=404', {integrity:text.sha256}, true, text.bytes); + await attempt('follow-valid', baseUrl + '/redirect', {integrity:text.sha256}, true, text.bytes); + await attempt('follow-invalid', baseUrl + '/redirect', {integrity:wrong256}, false, []); + await attempt('manual-valid', baseUrl + '/redirect', {integrity:text.sha256,redirect:'manual'}, false, []); + await attempt('manual-unsupported', baseUrl + '/redirect', {integrity:'sha1-ignored',redirect:'manual'}, false, []); + for (const [kind, integrity, wanted, truncate, abort] of [ + ['unvalidated', '', true, false, false], + ['valid', text.sha256, true, false, false], + ['invalid', wrong256, false, false, false], + ['unsupported', 'sha1-ignored', true, false, false], + ['truncated', text.sha256, false, true, false], + ['abort', text.sha256, false, false, true], + ]) { + const id = Math.random().toString(36).slice(2); + const controller = new AbortController(); + const reason = {aborted: true}; + let settled = false; + const outcome = fetch(baseUrl + '/gated?id=' + id + (truncate ? '&truncate=1' : ''), {integrity, signal:controller.signal}) + .then(response => { settled = true; return {response}; }, error => { settled = true; return {error}; }); + try { + for (let i = 0; i < 100; i++) { + if (await (await fetch(baseUrl + '/progress?id=' + id)).json()) break; + if (i === 99) throw new Error('gated server did not start'); + await new Promise(resolve => setTimeout(resolve, 10)); + } + await new Promise(resolve => setTimeout(resolve, 60)); + check('gated/' + kind + '/before-eof', settled, !integrity); + if (abort) controller.abort(reason); + } finally { await fetch(baseUrl + '/release?id=' + id); } + const result = await outcome; + if (abort) check('gated/' + kind + '/reason', result.error === reason, true); + else if (result.error) check('gated/' + kind + '/accepted', result.error instanceof TypeError ? false : String(result.error), wanted); + else { + check('gated/' + kind + '/accepted', true, wanted); + try { + const bytes = Array.from(new Uint8Array(await result.response.arrayBuffer())); + if (wanted) check('gated/' + kind + '/bytes', bytes, text.bytes); + } catch (error) { if (wanted) check('gated/' + kind + '/body-error', String(error), null); } + } + } + } + return {state: checks.every(check => check.pass) ? 'pass' : 'fail', checks}; +}