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}; +}