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 ac8ab43b52..95fd401b74 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 @@ -1846,6 +1846,12 @@ impl JsContextHost { else { return None; }; + if crate::network_host::response_has_null_body(&state.request_method, state.head.status) + { + // Fetch disregards enqueuing into a null response body. Do not + // retain, decode, or publish bytes from an invalid response. + return None; + } let context = v8::Local::new(scope, state.pending.execution_context.context_global()?); let xhr = v8::Local::new(scope, xhr); state.body_writer.append(bytes); diff --git a/moli-renderer-v8/src/network/request_client/tests.rs b/moli-renderer-v8/src/network/request_client/tests.rs index ac6cfddb1b..9afa0bce7d 100644 --- a/moli-renderer-v8/src/network/request_client/tests.rs +++ b/moli-renderer-v8/src/network/request_client/tests.rs @@ -1051,7 +1051,10 @@ async fn local_blob_method_errors_reach_streaming_and_callback_consumers() -> Re data.status, 200, "data: must not inherit the blob method restriction" ); - assert_eq!(data.body_text(), "payload"); + assert_eq!( + data.body_text(), + if method == "HEAD" { "" } else { "payload" } + ); } Ok(()) } diff --git a/moli-renderer-v8/src/network_host.rs b/moli-renderer-v8/src/network_host.rs index 34e5e5e9b2..7973765e93 100644 --- a/moli-renderer-v8/src/network_host.rs +++ b/moli-renderer-v8/src/network_host.rs @@ -159,7 +159,7 @@ pub(crate) use self::response::{ materialize_response_object_head, materialize_response_object_head_for_service_worker_respond_with, materialized_body_bytes_from_value, network_response_filter, response_constructor_callback, - validate_cors_preflight_response, validate_cors_response_chain, + response_has_null_body, validate_cors_preflight_response, validate_cors_response_chain, validate_cross_origin_embedder_and_document_isolation_policy, validate_cross_origin_resource_policy, validate_fetch_response_headers, validate_fetch_response_security_policy, validate_fetch_response_security_policy_with_body, diff --git a/moli-renderer-v8/src/network_host/browser_response.rs b/moli-renderer-v8/src/network_host/browser_response.rs index cdc4ddffc2..bb7c6f0dd8 100644 --- a/moli-renderer-v8/src/network_host/browser_response.rs +++ b/moli-renderer-v8/src/network_host/browser_response.rs @@ -71,7 +71,7 @@ pub(crate) fn local_url_response_with_blob_entry( method: &str, entry: Option<&CapturedBlobUrl>, ) -> Option> { - match url.scheme() { + let result = match url.scheme() { "blob" if method != "GET" => { Some(Err(format!("blob URL fetch requires GET, got `{method}`"))) } @@ -86,7 +86,16 @@ pub(crate) fn local_url_response_with_blob_entry( Some(data_url_response(url).ok_or_else(|| format!("data URL `{url}` is invalid"))) } _ => None, - } + }?; + Some(result.map(|response| { + if !response_has_null_body(method, response.head().status) { + return response; + } + // Apply the Fetch body filter before recording or delivering a local + // response, while preserving its status, MIME type and other headers. + let (head, _) = response.into_body(); + Response::from_head_and_lossy_body_bytes(head, Vec::new()) + })) } #[cfg(test)] diff --git a/moli-renderer-v8/src/network_host/response.rs b/moli-renderer-v8/src/network_host/response.rs index 066ad96c97..6c7501fbd9 100644 --- a/moli-renderer-v8/src/network_host/response.rs +++ b/moli-renderer-v8/src/network_host/response.rs @@ -9,6 +9,11 @@ use super::headers::{ }; use super::*; +/// Fetch's main-fetch body filter, given an already normalized request method. +pub(crate) fn response_has_null_body(method: &str, status: u16) -> bool { + matches!(method, "HEAD" | "CONNECT") || matches!(status, 101 | 103 | 204 | 205 | 304) +} + pub(crate) use self::bindings::response_constructor_callback; pub(in crate::network_host) use self::bindings::{ParsedResponseInit, parse_response_init}; pub(super) use self::body_methods::install_response_body_methods; diff --git a/moli-renderer-v8/src/network_host/response/materialize.rs b/moli-renderer-v8/src/network_host/response/materialize.rs index 1843476b70..e2eff08a65 100644 --- a/moli-renderer-v8/src/network_host/response/materialize.rs +++ b/moli-renderer-v8/src/network_host/response/materialize.rs @@ -15,12 +15,6 @@ pub(crate) struct FetchResponseRequest<'a> { pub(crate) mode: RequestMode, } -impl FetchResponseRequest<'_> { - fn has_null_body(self, status: u16) -> bool { - matches!(self.method, "HEAD" | "CONNECT") || matches!(status, 101 | 103 | 204 | 205 | 304) - } -} - fn is_redirect_status(status: u16) -> bool { matches!(status, 301 | 302 | 303 | 307 | 308) } @@ -222,7 +216,7 @@ pub(crate) fn build_fetch_response_object_from_body_source_for_request_mode_with .map(FetchResponseFilter::from) .unwrap_or_else(|| response_filter(&request_origin, &head, request.mode)); let obj = build_fetch_response_object_head(scope, &request_origin, &head, filter, None); - let body_stream = if request.has_null_body(head.status) { + let body_stream = if response_has_null_body(request.method, head.status) { None } else if filtered_response_exposes_body(filter) { network_body_stream_from_response_body(scope, obj, body) @@ -243,7 +237,7 @@ pub(crate) fn build_fetch_response_object_from_subresource_body_for_request_mode let request_origin = request_origin.into(); let filter = response_filter(&request_origin, &head, request.mode); let obj = build_fetch_response_object_head(scope, &request_origin, &head, filter, None); - let body_stream = if request.has_null_body(head.status) { + let body_stream = if response_has_null_body(request.method, head.status) { None } else if filtered_response_exposes_body(filter) { Some(network_body_stream_from_subresource_body(scope, obj, body)) @@ -333,7 +327,7 @@ fn build_fetch_response_object_from_stream_for_request_mode_with_surface_url<'s> filter, filtered_surface_url, ); - if request.has_null_body(head.status) { + if response_has_null_body(request.method, head.status) { // Fetch nulls the internal body, including for filtered responses. Do // not register a body source: subsequent transport chunks and terminal // events then have no stream to enqueue into or retain bytes for. diff --git a/moli-renderer-v8/src/network_host/xhr/delivery/progress.rs b/moli-renderer-v8/src/network_host/xhr/delivery/progress.rs index 1cd11442b7..4dc97642fb 100644 --- a/moli-renderer-v8/src/network_host/xhr/delivery/progress.rs +++ b/moli-renderer-v8/src/network_host/xhr/delivery/progress.rs @@ -61,7 +61,8 @@ pub(super) fn flush_xhr_streaming_progress( internal_id: u64, ) -> bool { if !xhr_stream_is_current(scope, xhr, internal_id) { - clear_xhr_progress_throttle(scope, xhr); + // open()/abort() retire the old progress state. A stale completion + // must not clear the timer or deferred progress of a newer request. return false; } diff --git a/moli-renderer-v8/src/network_host/xhr/delivery/response.rs b/moli-renderer-v8/src/network_host/xhr/delivery/response.rs index 01c96ad976..3b8115b3a5 100644 --- a/moli-renderer-v8/src/network_host/xhr/delivery/response.rs +++ b/moli-renderer-v8/src/network_host/xhr/delivery/response.rs @@ -125,6 +125,13 @@ fn apply_xhr_response_body( status_text: Option<&str>, mode: XhrResponseDeliveryMode, ) { + // Fulfilled and worker responses can bypass the HTTP transport's body + // filtering. Discard forbidden bytes before decoding any response type. + let body_bytes = if xhr_response_has_null_body(scope, xhr, &head) { + Vec::new() + } else { + body_bytes + }; let Some(response_type) = prepare_xhr_response(scope, xhr, &head, status_text, mode) else { return; }; @@ -200,6 +207,7 @@ fn apply_xhr_response_body( ); } +/// Returns whether the transport state should remain registered. pub(crate) fn apply_xhr_streaming_response_head( scope: &mut v8::PinScope<'_, '_>, xhr: v8::Local<'_, v8::Object>, @@ -211,8 +219,28 @@ pub(crate) fn apply_xhr_streaming_response_head( } set_xhr_response_head(scope, xhr, head, None); super::super::events::xhr_fire_readystatechange(scope, xhr, 2); - !scope.is_execution_terminating() - && super::progress::xhr_stream_is_current(scope, xhr, internal_id) + if scope.is_execution_terminating() + || !super::progress::xhr_stream_is_current(scope, xhr, internal_id) + { + return false; + } + if xhr_response_has_null_body(scope, xhr, head) { + // XHR handles a null body's end immediately after HEADERS_RECEIVED. + // Keep transport bookkeeping alive, but retire this XHR delivery so + // subsequent chunks and terminal errors cannot alter its response. + let generation = xhr_state_number_property(scope, xhr, XHR_OPEN_GENERATION_SLOT); + apply_xhr_streaming_response_body_source( + scope, + xhr, + head.clone(), + moli_fetch::ResponseBody::materialized_bytes(Vec::new()), + internal_id, + ); + return !scope.is_execution_terminating() + && !xhr_is_aborted(scope, xhr) + && xhr_state_number_property(scope, xhr, XHR_OPEN_GENERATION_SLOT) == generation; + } + true } pub(crate) fn apply_xhr_streaming_response_chunk( @@ -324,7 +352,7 @@ fn finish_xhr_response( matches!(mode, XhrResponseDeliveryMode::Buffered) && xhr_is_async(scope, xhr); set_xhr_state_string(scope, xhr, XHR_RESPONSE_TEXT_SLOT, response_text); set_xhr_state_value(scope, xhr, XHR_RESPONSE_SLOT, response_val); - if dispatch_intermediate_events { + if dispatch_intermediate_events && progress.loaded > 0.0 { super::super::events::xhr_fire_readystatechange(scope, xhr, 3); if scope.is_execution_terminating() { return; @@ -333,7 +361,11 @@ fn finish_xhr_response( return; } } - if dispatch_intermediate_events { + // A zero-byte stream has no chunk that could have dispatched progress. + // It still needs the end-of-body event, without synthesizing LOADING. + if xhr_is_async(scope, xhr) + && (matches!(mode, XhrResponseDeliveryMode::Buffered) || progress.loaded == 0.0) + { xhr_dispatch_progress_event_with_length_computable( scope, xhr, @@ -382,12 +414,14 @@ fn xhr_response_progress(head: &moli_fetch::ResponseHead, loaded: f64) -> XhrRes if matches!(head.final_url.scheme(), "data" | "blob") { return XhrResponseProgress { loaded, - length_computable: true, + length_computable: loaded > 0.0, total: loaded, }; } - let total = identity_encoded_content_length(&head.headers).map(|value| value as f64); + let total = identity_encoded_content_length(&head.headers) + .filter(|value| *value > 0) + .map(|value| value as f64); XhrResponseProgress { loaded, length_computable: total.is_some(), @@ -449,6 +483,10 @@ fn parse_xhr_response_document<'s>( response_type: XmlHttpRequestResponseType, character_set: &str, ) -> v8::Local<'s, v8::Value> { + // An empty HTML body can create a Document, but a null body cannot. + if xhr_response_has_null_body(scope, xhr, head) { + return v8::null(scope).into(); + } // XHR defaults a missing or invalid response MIME type to text/xml. // Its XML MIME types include any +xml subtype, unlike DOMParser's enum. let mime = xhr_response_mime_essence(scope, xhr, &head.headers) @@ -463,6 +501,15 @@ fn parse_xhr_response_document<'s>( .unwrap_or_else(|| v8::null(scope).into()) } +fn xhr_response_has_null_body( + scope: &mut v8::PinScope<'_, '_>, + xhr: v8::Local<'_, v8::Object>, + head: &moli_fetch::ResponseHead, +) -> bool { + let method = xhr_state_string_property(scope, xhr, XHR_METHOD_SLOT).unwrap_or_default(); + response_has_null_body(&method, head.status) +} + fn build_xhr_response_document<'s>( scope: &mut v8::PinScope<'s, '_>, xhr: v8::Local<'_, v8::Object>, diff --git a/moli-renderer-v8/src/script_vm/subresource_fetch.rs b/moli-renderer-v8/src/script_vm/subresource_fetch.rs index 775564ddee..91866a51f2 100644 --- a/moli-renderer-v8/src/script_vm/subresource_fetch.rs +++ b/moli-renderer-v8/src/script_vm/subresource_fetch.rs @@ -4925,14 +4925,14 @@ impl ScriptVm { event_source_parser: None, xhr_response: Some(xhr_response), }); - let remains_current = + let keep_stream = crate::network_host::apply_xhr_streaming_response_head( scope, xhr, &observable_head, started.internal_id, ); - if !remains_current { + if !keep_stream { // Registering the stream before dispatching state 2 lets abort() // retire the transport synchronously. open() and isolate // termination also invalidate the old XHR generation; retire diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/mod.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/mod.rs index 48a6539990..792cf8f838 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/mod.rs @@ -6,6 +6,7 @@ mod dom; mod file_input; mod forms; mod misc; +mod null_body; mod open_validation; mod query_realms; mod response_type; diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/null_body.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/null_body.rs new file mode 100644 index 0000000000..43eb452385 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/null_body.rs @@ -0,0 +1,380 @@ +use super::*; +use crate::util::v8str; + +fn start_probe(vm: &mut crate::runtime::PageVmTaskExecutorTestHarness, probe: &str, worker: bool) { + let source = if worker { + let script = format!( + "Promise.resolve().then(() => {probe}).then(value => {{ postMessage(value); close(); }}, error => {{ postMessage(String(error.stack || error)); close(); }});" + ); + format!( + r#"globalThis.__nullBodyResult = 'pending'; + const worker = new Worker(URL.createObjectURL(new Blob([{}], {{type: 'text/javascript'}}))); + worker.onmessage = event => {{ globalThis.__nullBodyResult = event.data; }}; + worker.onerror = event => {{ globalThis.__nullBodyResult = event.message; event.preventDefault(); }};"#, + serde_json::to_string(&script).unwrap() + ) + } else { + format!( + r#"globalThis.__nullBodyResult = 'pending'; + Promise.resolve().then(() => {probe}).then( + value => {{ globalThis.__nullBodyResult = value; }}, + error => {{ globalThis.__nullBodyResult = String(error.stack || error); }});"# + ) + }; + vm.eval(&source).expect("start null response body probe"); +} + +#[tokio::test(flavor = "current_thread")] +async fn xhr_null_body_data_urls_preserve_headers_and_response_types_in_window_and_worker() { + for worker in [false, true] { + let loader = static_http_loader(std::iter::empty::()); + let mut vm = + new_page_task_executor_test_vm_with_loader("https://xhr-null-body.test/", &loader); + start_probe( + &mut vm, + r#" +(async () => { + const assert = (value, message) => { if (!value) throw new Error(message); }; + const worker = typeof document === 'undefined'; + for (const async of [true, false]) { + const cases = [ + ['', 'text/plain;charset=utf-8', 'hello'], + ['text', 'text/plain', 'hello'], + ['arraybuffer', 'application/octet-stream', '%00%FF'], + ['blob', 'application/octet-stream', '%00%FF'], + ['json', 'application/json', '%7B%22value%22%3A7%7D'], + ['document', 'text/html', '%3Cp%3Ehello'] + ]; + for (const [type, mime, body] of cases) { + if ((!async && !worker && type !== '') || (worker && type === 'document')) continue; + const xhr = new XMLHttpRequest(), events = []; + xhr.onreadystatechange = () => events.push(xhr.readyState); + for (const name of ['loadstart', 'progress', 'load', 'loadend']) { + xhr.addEventListener(name, e => events.push(`${name}:${e.loaded}:${e.total}:${e.lengthComputable}`)); + } + const done = new Promise(resolve => xhr.addEventListener('loadend', resolve)); + const url = `data:${mime},${body}`; + xhr.open('hEaD', url, async); + if (async || worker) xhr.responseType = type; + xhr.send(); + if (async) await done; + assert(xhr.status === 200 && xhr.responseURL === url, 'HEAD status and URL'); + assert(xhr.getResponseHeader('content-type') === mime, 'HEAD preserves MIME'); + assert(xhr.getResponseHeader('content-length') === null, 'data URLs do not acquire Content-Length'); + if (type === '' || type === 'text') assert(xhr.responseText === '' && xhr.response === '', 'HEAD text is empty'); + else if (type === 'arraybuffer') assert(xhr.response instanceof ArrayBuffer && xhr.response.byteLength === 0, 'HEAD ArrayBuffer is empty'); + else if (type === 'blob') assert(xhr.response instanceof Blob && xhr.response.size === 0 && xhr.response.type === mime, 'HEAD Blob is empty and retains MIME'); + else assert(xhr.response === null, 'HEAD JSON and Document are null'); + if (!worker && (type === '' || type === 'document')) assert(xhr.responseXML === null, 'HEAD has no response Document'); + const expected = async + ? [1, 'loadstart:0:0:false', 2, 'progress:0:0:false', 4, 'load:0:0:false', 'loadend:0:0:false'] + : [1, 4, 'load:0:0:false', 'loadend:0:0:false']; + assert(JSON.stringify(events) === JSON.stringify(expected), `HEAD ${type}/${async}: ${JSON.stringify(events)}`); + } + } + for (const method of ['GET', 'POST', 'PUT', 'DELETE', 'UNICORN']) { + const xhr = new XMLHttpRequest(); + const done = new Promise(resolve => xhr.onloadend = resolve); + xhr.open(method, 'data:text/plain;base64,aGVsbG8='); + xhr.send(); + await done; + assert(xhr.status === 200 && xhr.responseText === 'hello', method + ' keeps the data body'); + } + if (!worker) { + const xhr = new XMLHttpRequest(); + const done = new Promise(resolve => xhr.onloadend = resolve); + xhr.open('GET', 'data:text/html,'); + xhr.responseType = 'document'; + xhr.send(); + await done; + assert(xhr.response instanceof Document && xhr.response.documentElement.localName === 'html', 'an empty non-null HTML body still creates a Document'); + } + { + const xhr = new XMLHttpRequest(), events = []; + xhr.onprogress = () => { events.push('progress:' + xhr.readyState); xhr.abort(); }; + xhr.onabort = () => events.push('abort'); + xhr.onload = () => events.push('load'); + const done = new Promise(resolve => xhr.onloadend = () => { events.push('loadend'); resolve(); }); + xhr.open('HEAD', 'data:text/plain,hello'); + xhr.send(); + await done; + assert(xhr.readyState === 0 && xhr.status === 0, 'abort from zero-byte progress clears the response'); + assert(events.join(',') === 'progress:2,abort,loadend', 'abort from progress prevents load: ' + events); + } + for (const url of ['data:text/plain,hello', 'data:application/octet-stream;base64,AP8=']) { + const response = await fetch(url, {method: 'HEAD'}); + const clone = response.clone(); + assert(response.status === 200 && response.body === null && clone.body === null, 'Fetch keeps a null body'); + assert(await response.text() === '' && (await clone.arrayBuffer()).byteLength === 0, 'Fetch HEAD consumers are empty'); + assert(!response.bodyUsed && !clone.bodyUsed, 'null bodies remain undisturbed'); + } + const blobURL = URL.createObjectURL(new Blob(['hello'])); + for (const url of [blobURL, 'data:missing-comma']) { + const xhr = new XMLHttpRequest(); + let failed = false; + const done = new Promise(resolve => xhr.onloadend = resolve); + xhr.onerror = () => { failed = true; }; + xhr.open('HEAD', url); + xhr.send(); + await done; + assert(failed && xhr.status === 0, 'HEAD preserves local URL failures: ' + url); + } + URL.revokeObjectURL(blobURL); + return 'ok'; +})() +"#, + worker, + ); + advance_page_task_executor_until_eval_equals( + &mut vm, + &loader, + "globalThis.__nullBodyResult", + "ok", + "Window/Worker HEAD data response", + ) + .await; + } +} + +#[test] +fn xhr_null_body_statuses_discard_buffered_and_streamed_bytes() { + for streaming in [false, true] { + for (method, status) in [ + ("HEAD", 200), + ("GET", 101), + ("GET", 103), + ("GET", 204), + ("POST", 205), + ("GET", 304), + ] { + for response_type in ["", "text", "json", "arraybuffer", "blob", "document"] { + let mut vm = new_storage_test_vm("https://xhr-null-status.test/"); + vm.set_fetch_subresource_interception( + true, + Some(crate::types::SubresourceResourceType::Xhr), + ); + vm.eval(&format!(r#" + globalThis.xhr = new XMLHttpRequest(); + globalThis.events = []; + xhr.onreadystatechange = () => events.push(xhr.readyState); + for (const name of ['progress', 'load', 'loadend']) + xhr.addEventListener(name, e => events.push(`${{name}}:${{e.loaded}}:${{e.total}}:${{e.lengthComputable}}`)); + xhr.open('{method}', '/response'); + xhr.responseType = '{response_type}'; + xhr.send(); + "#)).unwrap(); + let pending = vm.take_pending_subresource_fetch_infos(); + assert_eq!(pending.len(), 1); + let request = &pending[0]; + let head = moli_fetch::ResponseHead { + final_url: request.url.clone(), + status, + status_text: Some("Preserved".to_owned()), + headers: vec![ + ("Content-Type".to_owned(), "text/html".to_owned()), + ("Content-Length".to_owned(), "7".to_owned()), + ], + request_cookie_report: None, + cookie_set_reports: Vec::new(), + redirected: false, + redirect_chain: Vec::new(), + from_cache: false, + negotiated_http_version: None, + }; + if streaming { + let body_source_id = crate::network_host::new_network_body_source_id(); + vm.start_streaming_async_subresource_fetch( + crate::types::AsyncSubresourceStreamingStarted { + skip_fetch_security_validation: false, + response_filter: None, + internal_id: request.internal_id, + request_url: request.url.clone(), + request_method: method.to_owned(), + request_headers: Vec::new(), + request_body: None, + body_source_id, + head, + network_request_headers: None, + }, + ) + .unwrap(); + let complete_events = + r#"[1,2,"progress:0:7:true",4,"load:0:7:true","loadend:0:7:true"]"#; + assert_eq!( + vm.eval("JSON.stringify(events)").unwrap(), + complete_events, + "a null body completes at headers, before transport EOF: {method}/{status}/{response_type}" + ); + vm.append_streaming_async_subresource_fetch_chunk( + body_source_id, + b"

body".to_vec(), + ); + assert_eq!( + vm.eval("JSON.stringify(events)").unwrap(), + complete_events, + "null-body chunks must not expose additional events: {method}/{status}/{response_type}" + ); + vm.finish_streaming_async_subresource_fetch( + request.internal_id, + body_source_id, + if status == 205 { + Err("invalid response body was truncated".to_owned()) + } else { + Ok(()) + }, + ) + .unwrap(); + } else { + let context_ptr: *const v8::Global = &vm.page_default_context; + vm.renderer_document_isolate + .with_entered_renderer_document_isolate(move |isolate| { + let scope = std::pin::pin!(v8::HandleScope::new(isolate)); + let scope = &mut scope.init(); + let context = unsafe { v8::Local::new(scope, &*context_ptr) }; + let scope = &mut v8::ContextScope::new(scope, context); + let xhr = context + .global(scope) + .get(scope, v8str(scope, "xhr").into()) + .unwrap() + .to_object(scope) + .unwrap(); + crate::network_host::apply_xhr_response_body_source( + scope, + xhr, + head, + moli_fetch::ResponseBody::materialized_bytes(b"

body".to_vec()), + ); + Ok(()) + }) + .unwrap(); + } + assert_eq!(vm.eval(r#"JSON.stringify([ + xhr.status, xhr.statusText, xhr.getResponseHeader('content-type'), xhr.getResponseHeader('content-length'), + xhr.responseType === 'arraybuffer' ? xhr.response.byteLength === 0 : + xhr.responseType === 'blob' ? xhr.response.size === 0 && xhr.response.type === 'text/html' : + xhr.responseType === 'document' || xhr.responseType === 'json' ? xhr.response === null : xhr.responseText === '', + events])"#).unwrap(), serde_json::json!([status, "Preserved", "text/html", "7", true, + [1, 2, "progress:0:7:true", 4, "load:0:7:true", "loadend:0:7:true"]]).to_string(), + "streaming={streaming}, {method}/{status}/{response_type}"); + } + } + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn xhr_null_body_empty_http_and_data_responses_finish_without_loading() { + for worker in [false, true] { + let server = StaticHttpServer::spawn_with_bodies(vec![String::new()]).await; + let url = server.base_url(); + let loader = static_http_loader(std::iter::empty::()); + let mut vm = new_page_task_executor_test_vm_with_loader(url.as_str(), &loader); + let probe = r#" +(async () => { + const assert = (value, message) => { if (!value) throw new Error(message); }; + for (const url of [HTTP_URL, 'data:text/plain,', URL.createObjectURL(new Blob([]))]) { + const xhr = new XMLHttpRequest(), events = []; + xhr.onreadystatechange = () => events.push(xhr.readyState); + for (const name of ['loadstart', 'progress', 'load', 'loadend']) + xhr.addEventListener(name, e => events.push(`${name}:${e.loaded}:${e.total}:${e.lengthComputable}`)); + const done = new Promise(resolve => xhr.onloadend = resolve); + xhr.open('GET', url); + xhr.send(); + await done; + assert(xhr.status === 200 && xhr.responseText === '', 'empty response succeeds'); + assert(JSON.stringify(events) === JSON.stringify([1, 'loadstart:0:0:false', 2, 'progress:0:0:false', 4, 'load:0:0:false', 'loadend:0:0:false']), JSON.stringify(events)); + if (url.startsWith('blob:')) URL.revokeObjectURL(url); + } + return 'ok'; +})() +"#.replace("HTTP_URL", &serde_json::to_string(url.as_str()).unwrap()); + start_probe(&mut vm, &probe, worker); + advance_page_task_executor_until_eval_equals( + &mut vm, + &loader, + "globalThis.__nullBodyResult", + "ok", + "empty HTTP/local XHR response", + ) + .await; + assert_eq!(server.finish_targets().await, ["/"]); + } +} + +#[test] +fn xhr_null_body_late_completion_preserves_a_replacement_requests_progress() { + let mut vm = new_storage_test_vm("https://xhr-null-body-reuse.test/"); + vm.set_fetch_subresource_interception(true, Some(crate::types::SubresourceResourceType::Xhr)); + vm.eval( + r#" + globalThis.xhr = new XMLHttpRequest(); + xhr.onprogress = event => progressEvents.push(event.loaded); + "#, + ) + .unwrap(); + let mut streams = Vec::new(); + for (method, path) in [("HEAD", "/head"), ("GET", "/replacement")] { + vm.eval(&format!( + "globalThis.progressEvents = []; xhr.open('{method}', '{path}'); xhr.send();" + )) + .unwrap(); + let pending = vm.take_pending_subresource_fetch_infos(); + assert_eq!(pending.len(), 1); + let request = &pending[0]; + let body_source_id = crate::network_host::new_network_body_source_id(); + vm.start_streaming_async_subresource_fetch( + crate::types::AsyncSubresourceStreamingStarted { + skip_fetch_security_validation: false, + response_filter: None, + internal_id: request.internal_id, + request_url: request.url.clone(), + request_method: method.to_owned(), + request_headers: Vec::new(), + request_body: None, + body_source_id, + head: moli_fetch::ResponseHead { + final_url: request.url.clone(), + status: 200, + status_text: None, + headers: vec![ + ("Content-Type".to_owned(), "text/plain".to_owned()), + ("Content-Length".to_owned(), "3".to_owned()), + ], + request_cookie_report: None, + cookie_set_reports: Vec::new(), + redirected: false, + redirect_chain: Vec::new(), + from_cache: false, + negotiated_http_version: None, + }, + network_request_headers: None, + }, + ) + .unwrap(); + streams.push((request.internal_id, body_source_id)); + assert_eq!( + vm.eval("xhr.readyState").unwrap(), + if method == "HEAD" { "4" } else { "2" } + ); + } + let (old_id, old_body) = streams[0]; + let (new_id, new_body) = streams[1]; + vm.append_streaming_async_subresource_fetch_chunk(new_body, b"a".to_vec()); + vm.append_streaming_async_subresource_fetch_chunk(new_body, b"b".to_vec()); + vm.finish_streaming_async_subresource_fetch(old_id, old_body, Ok(())) + .unwrap(); + vm.append_streaming_async_subresource_fetch_chunk(new_body, b"c".to_vec()); + assert_eq!( + vm.eval("JSON.stringify([xhr.readyState, xhr.responseText, progressEvents])") + .unwrap(), + r#"[3,"abc",[1]]"#, + "the old HEAD completion must not cancel the replacement's progress gate" + ); + vm.finish_streaming_async_subresource_fetch(new_id, new_body, Ok(())) + .unwrap(); + assert_eq!( + vm.eval("JSON.stringify([xhr.readyState, xhr.responseText, progressEvents])") + .unwrap(), + r#"[4,"abc",[1,3]]"#, + "replacement completion still flushes its last deferred progress event" + ); +} diff --git a/moli-renderer-v8/src/worker/thread/tests/network.rs b/moli-renderer-v8/src/worker/thread/tests/network.rs index c4ecc6f64f..0ca37ad266 100644 --- a/moli-renderer-v8/src/worker/thread/tests/network.rs +++ b/moli-renderer-v8/src/worker/thread/tests/network.rs @@ -2880,8 +2880,14 @@ async fn worker_xmlhttprequest_uses_worker_script_base_url_and_event_target_list #[tokio::test] async fn worker_xhr_request_stage_interception_can_fulfill_synthetic_response() { ensure_v8(); - let mut handle = spawn_worker_with_request_client_and_network_policy( - r#" + for (status, expected_text) in [ + (200, "fulfilled-worker-xhr"), + (204, ""), + (205, ""), + (304, ""), + ] { + let mut handle = spawn_worker_with_request_client_and_network_policy( + r#" onmessage = () => { const xhr = new XMLHttpRequest(); xhr.onloadend = () => { @@ -2897,54 +2903,55 @@ async fn worker_xhr_request_stage_interception_can_fulfill_synthetic_response() xhr.send("payload"); }; "# - .into(), - "http://example.test/worker/main.js".into(), - ResourceRequestClient::new(&FetchConfig::default()).expect("worker xhr loader"), - WorkerNetworkPolicy { - network_partition_key: Some("credentialless-worker-xhr".to_owned()), - ..WorkerNetworkPolicy::default() - }, - ); - handle.set_fetch_subresource_interception(true, Some(SubresourceResourceType::Xhr)); - handle.post_message(serialize_test_string("go")); + .into(), + "http://example.test/worker/main.js".into(), + ResourceRequestClient::new(&FetchConfig::default()).expect("worker xhr loader"), + WorkerNetworkPolicy { + network_partition_key: Some("credentialless-worker-xhr".to_owned()), + ..WorkerNetworkPolicy::default() + }, + ); + handle.set_fetch_subresource_interception(true, Some(SubresourceResourceType::Xhr)); + handle.post_message(serialize_test_string("go")); - let pending = timeout(TIMEOUT, handle.recv()) - .await - .expect("timed out waiting for worker xhr pause") - .expect("worker channel closed"); - let WorkerToParentMessage::PendingSubresourceFetch(pending) = pending else { - panic!("expected worker xhr pause, got {pending:?}"); - }; - assert!(pending.info.network_request_handle.is_none()); - assert_eq!(pending.info.resource_type, SubresourceResourceType::Xhr); - assert_eq!( - pending.info.url.as_str(), - "http://example.test/intercepted-worker-xhr" - ); - assert_eq!(pending.info.request_body.as_deref(), Some("payload")); - assert_eq!( - pending.network_partition_key.as_deref(), - Some("credentialless-worker-xhr") - ); + let pending = timeout(TIMEOUT, handle.recv()) + .await + .expect("timed out waiting for worker xhr pause") + .expect("worker channel closed"); + let WorkerToParentMessage::PendingSubresourceFetch(pending) = pending else { + panic!("expected worker xhr pause, got {pending:?}"); + }; + assert!(pending.info.network_request_handle.is_none()); + assert_eq!(pending.info.resource_type, SubresourceResourceType::Xhr); + assert_eq!( + pending.info.url.as_str(), + "http://example.test/intercepted-worker-xhr" + ); + assert_eq!(pending.info.request_body.as_deref(), Some("payload")); + assert_eq!( + pending.network_partition_key.as_deref(), + Some("credentialless-worker-xhr") + ); - let request = pending_worker_xhr_continue(pending.fetch_id, 31, &pending.info, false); - handle.fulfill_pending_xhr( - request, - 204, - vec![ - ("content-type".to_owned(), "text/plain".to_owned()), - ( - "x-worker-xhr-intercept".to_owned(), - "request-stage".to_owned(), - ), - ], - RendererSyntheticResponseBody::from_bytes(b"fulfilled-worker-xhr".to_vec()), - ); + let request = pending_worker_xhr_continue(pending.fetch_id, 31, &pending.info, false); + handle.fulfill_pending_xhr( + request, + status, + vec![ + ("content-type".to_owned(), "text/plain".to_owned()), + ( + "x-worker-xhr-intercept".to_owned(), + "request-stage".to_owned(), + ), + ], + RendererSyntheticResponseBody::from_bytes(b"fulfilled-worker-xhr".to_vec()), + ); - assert_eq!( - recv_post_json(&mut handle).await, - r#"{"readyState":4,"status":204,"header":"request-stage","text":"fulfilled-worker-xhr"}"# - ); + assert_eq!( + serde_json::from_str::(&recv_post_json(&mut handle).await).unwrap(), + serde_json::json!({"readyState":4,"status":status,"header":"request-stage","text":expected_text}) + ); + } } #[tokio::test]