From c5ea98717967de31fdafb032a0bd291efea0d90f Mon Sep 17 00:00:00 2001 From: ldm0 Date: Sun, 13 Sep 2026 01:59:47 +0800 Subject: [PATCH] fix(fetch): keep data URL responses basic across request modes Apply the data-scheme exception when selecting response filtering and the observable response type. Data URL origins remain opaque, while no-cors fetches retain their status, immutable headers and response body. Cover Window and opaque-origin Worker fetches across request modes, redirect modes, GET/HEAD/POST, Request clones, URL fragments, MIME types, binary body reads, response clones and bodyUsed behavior. --- .../src/network_host/response/materialize.rs | 13 ++- .../script_vm/tests/dom_xhr/data_response.rs | 87 +++++++++++++++++++ .../src/script_vm/tests/dom_xhr/mod.rs | 1 + 3 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 moli-renderer-v8/src/script_vm/tests/dom_xhr/data_response.rs diff --git a/moli-renderer-v8/src/network_host/response/materialize.rs b/moli-renderer-v8/src/network_host/response/materialize.rs index 7065fbda8b..ef10aa2a33 100644 --- a/moli-renderer-v8/src/network_host/response/materialize.rs +++ b/moli-renderer-v8/src/network_host/response/materialize.rs @@ -107,6 +107,7 @@ pub(crate) fn network_response_filter( if redirect_mode == RequestRedirectMode::Manual && is_redirect_status(head.status) { Some(AsyncSubresourceFetchResponseFilter::OpaqueRedirect) } else if request_mode == RequestMode::NoCors + && head.final_url.scheme() != "data" && head.url_list().has_cross_origin_url(&request_origin) { Some(AsyncSubresourceFetchResponseFilter::Opaque) @@ -121,16 +122,20 @@ fn compute_fetch_response_type( filter: FetchResponseFilter, ) -> &'static str { let request_origin = request_origin.into(); - // Returning to the initiating origin does not undo response tainting. + // Main fetch selects basic tainting for data URLs regardless of request mode. + // HTTP responses retain cross-origin taint after a redirect back to the client. match filter { FetchResponseFilter::Basic => "basic", FetchResponseFilter::Cors => "cors", FetchResponseFilter::Opaque => "opaque", FetchResponseFilter::OpaqueRedirect => "opaqueredirect", - FetchResponseFilter::None if head.url_list().has_cross_origin_url(&request_origin) => { - "cors" + FetchResponseFilter::None + if head.final_url.scheme() == "data" + || !head.url_list().has_cross_origin_url(&request_origin) => + { + "basic" } - FetchResponseFilter::None => "basic", + FetchResponseFilter::None => "cors", } } diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/data_response.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/data_response.rs new file mode 100644 index 0000000000..d5826c7065 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/data_response.rs @@ -0,0 +1,87 @@ +use super::*; + +async fn check_data_response_modes(worker: bool) { + let loader = static_http_loader(std::iter::empty::()); + let mut vm = new_page_task_executor_test_vm_with_loader("https://data-response.test/", &loader); + let probe = r#"(async () => { + const cases = [ + ['data:,local%20body#fragment', 'text/plain;charset=US-ASCII', [108,111,99,97,108,32,98,111,100,121]], + ['data:application/octet-stream;base64,AP+A', 'application/octet-stream', [0,255,128]], + ['data:text/plain;charset=utf-8,%E4%B8%AD', 'text/plain;charset=utf-8', [228,184,173]] + ]; + let count = 0; + for (const [url,mime,bytes] of cases) { + if (new URL(url).origin !== 'null') throw new Error('data URL origin must remain opaque'); + for (const mode of ['cors','same-origin','no-cors']) { + for (const redirect of ['follow','manual','error']) { + for (const method of ['GET','HEAD','POST']) { + for (const input of ['url','request']) { + const label = [url,mode,redirect,method,input].join('/'); + const assert = (condition,message) => { if (!condition) throw new Error(label+': '+message); }; + const init = {mode,redirect,method}; + const response = input === 'url' + ? await fetch(url,init) + : await fetch(new Request(url,{mode,method,redirect:'follow'}).clone(),{redirect}); + assert(response instanceof Response,'Response'); + assert(response.type === 'basic','type='+response.type); + assert(response.status === 200 && response.statusText === 'OK' && response.ok,'status'); + assert(response.url === url.split('#')[0] && !response.redirected,'URL and redirected'); + assert(response.headers.get('Content-Type') === mime,'Content-Type='+response.headers.get('Content-Type')); + assert(response.headers.get('Content-Length') === null,'no synthesized Content-Length'); + assert(!response.bodyUsed,'body initially unused'); + assert((response.body === null) === (method === 'HEAD'),'null body for HEAD'); + let headerError; + try { response.headers.set('X-Author','changed'); } catch(error) { headerError = error; } + assert(headerError instanceof TypeError,'immutable fetch headers'); + const clone = response.clone(); + assert(clone.type === 'basic' && clone.status === 200 && clone.url === response.url,'clone surface'); + assert(clone.headers.get('Content-Type') === mime,'clone Content-Type'); + const expected = method === 'HEAD' ? [] : bytes; + const actual = Array.from(new Uint8Array(await response.arrayBuffer())); + const cloned = Array.from(new Uint8Array(await clone.arrayBuffer())); + assert(JSON.stringify(actual) === JSON.stringify(expected),'body bytes '+actual); + assert(JSON.stringify(cloned) === JSON.stringify(expected),'clone bytes '+cloned); + assert(response.bodyUsed === (method !== 'HEAD') && clone.bodyUsed === (method !== 'HEAD'),'bodyUsed'); + count++; + } + } + } + } + } + return String(count); +})()"#; + let script = if worker { + let worker_source = format!( + "Promise.resolve().then(() => {probe}).then(value => {{ postMessage(value); close(); }}, error => {{ postMessage(String(error.stack || error)); close(); }});" + ); + format!( + "globalThis.dataResponseResult = 'pending'; const worker = new Worker('data:text/javascript,' + encodeURIComponent({})); worker.onmessage = event => {{ dataResponseResult = event.data; }}; worker.onerror = event => {{ dataResponseResult = event.message; event.preventDefault(); }};", + serde_json::to_string(&worker_source).unwrap() + ) + } else { + format!( + "globalThis.dataResponseResult = 'pending'; Promise.resolve().then(() => {probe}).then(value => {{ dataResponseResult = value; }}, error => {{ dataResponseResult = String(error.stack || error); }});" + ) + }; + vm.eval(&script).unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while vm.eval("dataResponseResult === 'pending'").unwrap() == "true" { + wait_for_one_selected_page_task_executor_test_turn(&mut vm, &loader) + .await + .unwrap(); + } + }) + .await + .expect("data URL fetch matrix should settle locally"); + assert_eq!(vm.eval("dataResponseResult").unwrap(), "162"); +} + +#[tokio::test] +async fn data_response_preserves_basic_filter_in_window() { + check_data_response_modes(false).await; +} + +#[tokio::test] +async fn data_response_preserves_basic_filter_in_opaque_origin_worker() { + check_data_response_modes(true).await; +} 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 1cf07a6549..537bb3484a 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 @@ -5,6 +5,7 @@ mod cors_preflight; mod cors_redirect; mod cors_response; mod cssom; +mod data_response; mod dom; mod file_input; mod forms;