mirror of
https://github.com/lexmount/moli.git
synced 2026-10-08 16:01:05 +00:00
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.
This commit is contained in:
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
use super::*;
|
||||
|
||||
async fn check_data_response_modes(worker: bool) {
|
||||
let loader = static_http_loader(std::iter::empty::<String>());
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user