From db79f4a7461b92e95d4be9ab014e47ca560c5c5f Mon Sep 17 00:00:00 2001 From: ldm0 Date: Sat, 12 Sep 2026 10:59:59 +0800 Subject: [PATCH] fix(xhr): apply enum attribute rules to responseType Ignore unsupported responseType values without resetting the selected type, including historical moz-* tokens. Convert the value to a string before XHR state checks and preserve conversion exceptions and reentrant state changes. A missing setter argument is converted like undefined and ignored. Validate native getter/setter receivers before conversion. Ignore document in Workers before checking LOADING or DONE, while retaining InvalidStateError and InvalidAccessError for valid values where the XHR setter algorithm requires it. Cover Window and Worker conversion, borrowed accessors, forged receivers, response identity, and open/send reentrancy over real HTTP. Correct the legacy test expectations for invalid enum assignments and historical moz-* tokens. https://webidl.spec.whatwg.org/#dfn-attribute-setter https://xhr.spec.whatwg.org/#the-responsetype-attribute Validation: cargo fmt --all; workspace Clippy with all targets/features and -D warnings; cargo nextest run --no-fail-fast (18161 passed, 13 skipped). 192 upstream WPT cases: 175 -> 178 pass, 726 -> 748 passing subtests, no new regressions. Window and Worker responseType matrices both pass 50/50. --- .../network_host/xhr/bindings/constants.rs | 27 +- .../src/network_host/xhr/response_type.rs | 25 +- .../src/script_vm/tests/dom_xhr/mod.rs | 1 + .../script_vm/tests/dom_xhr/response_type.rs | 253 ++++++++++++++++++ .../src/script_vm/tests/dom_xhr/xhr.rs | 12 +- moli-wpt-compat/fixtures/wpt/manifest.toml | 2 +- .../worker/worker-xhr-responsetype-basic.html | 4 +- 7 files changed, 284 insertions(+), 40 deletions(-) create mode 100644 moli-renderer-v8/src/script_vm/tests/dom_xhr/response_type.rs diff --git a/moli-renderer-v8/src/network_host/xhr/bindings/constants.rs b/moli-renderer-v8/src/network_host/xhr/bindings/constants.rs index ab85ee7b8c..8a22b234e7 100644 --- a/moli-renderer-v8/src/network_host/xhr/bindings/constants.rs +++ b/moli-renderer-v8/src/network_host/xhr/bindings/constants.rs @@ -37,6 +37,7 @@ struct XmlHttpRequestPrototypeAccessorsDeclaration { accessor_property, getter = xhr_string_getter, setter = xhr_response_type_setter, + receiver = web_api_interfaces::XMLHttpRequest::is_instance, data = callback_data_index_value(scope, 4) )] response_type: (), @@ -105,8 +106,8 @@ struct XhrTimeoutArgs { #[derive(webidl::WebIdlArgs)] #[webidl(prefix = "XMLHttpRequest.responseType")] struct XhrResponseTypeArgs { - #[webidl(required, converter = "enum")] - value: XmlHttpRequestResponseType, + #[webidl(default = "undefined", converter = "dom_string")] + value: String, } #[derive(webidl::WebIdlArgs)] @@ -356,6 +357,19 @@ fn xhr_response_type_setter<'s>( let Some(xhr) = args.this().to_object(scope) else { return; }; + let Some(parsed) = webidl::parse_args::(scope, &args) else { + return; + }; + // WebIDL enum attribute setters ignore unknown tokens after ToString, + // before running the attribute's state checks. + let Some(response_type) = XmlHttpRequestResponseType::parse(&parsed.value) else { + return; + }; + if response_type == XmlHttpRequestResponseType::Document + && xhr_current_context_is_worker_global(scope) + { + return; + } let ready_state = xhr_state_number_property(scope, xhr, XHR_READY_STATE_SLOT).unwrap_or(0.0); if matches!(ready_state as u32, 3 | 4) { xhr_throw_invalid_state( @@ -364,10 +378,6 @@ fn xhr_response_type_setter<'s>( ); return; } - let Some(parsed) = webidl::parse_args::(scope, &args) else { - return; - }; - let response_type = parsed.value; let async_request = xhr_state_bool_property(scope, xhr, XHR_ASYNC_SLOT).unwrap_or(true); if xhr_is_synchronous_document_request(scope, async_request) { xhr_throw_invalid_access( @@ -376,11 +386,6 @@ fn xhr_response_type_setter<'s>( ); return; } - if response_type == XmlHttpRequestResponseType::Document - && xhr_current_context_is_worker_global(scope) - { - return; - } set_xhr_state_string(scope, xhr, XHR_RESPONSE_TYPE_SLOT, response_type.label()); } diff --git a/moli-renderer-v8/src/network_host/xhr/response_type.rs b/moli-renderer-v8/src/network_host/xhr/response_type.rs index 2399216c72..2b321f123d 100644 --- a/moli-renderer-v8/src/network_host/xhr/response_type.rs +++ b/moli-renderer-v8/src/network_host/xhr/response_type.rs @@ -1,17 +1,7 @@ use super::{XHR_RESPONSE_TYPE_SLOT, xhr_state_string_property}; use std::str::FromStr; -#[derive( - Clone, - Copy, - Debug, - PartialEq, - Eq, - strum::EnumString, - strum::IntoStaticStr, - crate::webidl::WebIdlEnum, -)] -#[webidl(name = "XMLHttpRequestResponseType", parse_with = Self::parse)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, strum::EnumString, strum::IntoStaticStr)] #[strum(serialize_all = "lowercase")] pub(super) enum XmlHttpRequestResponseType { #[strum(serialize = "")] @@ -25,12 +15,6 @@ pub(super) enum XmlHttpRequestResponseType { impl XmlHttpRequestResponseType { pub(super) fn parse(value: &str) -> Option { - if matches!( - value, - "moz-blob" | "moz-chunked-text" | "moz-chunked-arraybuffer" - ) { - return Some(Self::Default); - } Self::from_str(value).ok() } @@ -78,12 +62,9 @@ mod tests { } #[test] - fn xhr_response_type_maps_historical_moz_tokens_to_default() { + fn xhr_response_type_rejects_historical_moz_tokens() { for raw in ["moz-blob", "moz-chunked-text", "moz-chunked-arraybuffer"] { - let parsed = XmlHttpRequestResponseType::parse(raw) - .expect("historical XHR responseType token should parse"); - assert_eq!(parsed, XmlHttpRequestResponseType::Default); - assert_eq!(parsed.label(), ""); + assert!(XmlHttpRequestResponseType::parse(raw).is_none()); } } } 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 bacd74e0eb..48a6539990 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 @@ -8,6 +8,7 @@ mod forms; mod misc; mod open_validation; mod query_realms; +mod response_type; mod shadow_dom; mod streaming_failure; mod style_invalidation; diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/response_type.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/response_type.rs new file mode 100644 index 0000000000..687637beea --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/response_type.rs @@ -0,0 +1,253 @@ +use super::*; + +const CONVERSION_PROBE: &str = r#" +(() => { + const assert = (value, message) => { if (!value) throw new Error(message); }; + const worker = typeof document === 'undefined'; + const descriptor = Object.getOwnPropertyDescriptor(XMLHttpRequest.prototype, 'responseType'); + const xhr = new XMLHttpRequest(); + const invalidValues = [ + 'JSON', 'arrayBuffer', 'nosuchtype', ' text ', 'text/html', 'json\0', '\ud800', + 'moz-blob', 'moz-chunked-text', 'moz-chunked-arraybuffer', + undefined, null, false, 0, NaN, Infinity, 1n, {}, ['JSON'] + ]; + for (const type of ['', 'arraybuffer', 'blob', 'document', 'json', 'text']) { + xhr.responseType = 'text'; + xhr.responseType = type; + const expected = worker && type === 'document' ? 'text' : type; + assert(xhr.responseType === expected, 'valid enum value: ' + type); + for (const invalid of invalidValues) { + xhr.responseType = invalid; + assert(xhr.responseType === expected, 'invalid enum assignment preserves ' + expected); + } + assert(descriptor.set.call(xhr) === undefined, 'omitted setter value is ignored'); + assert(xhr.responseType === expected, 'omitted value preserves the response type'); + } + let conversions = 0; + xhr.responseType = { + [Symbol.toPrimitive](hint) { + assert(hint === 'string', 'enum conversion uses the string hint'); + ++conversions; + return 'json'; + }, + toString() { throw new Error('unexpected second conversion'); } + }; + assert(conversions === 1 && xhr.responseType === 'json', 'convert once'); + xhr.responseType = { toString() { xhr.responseType = 'blob'; return 'invalid'; } }; + assert(xhr.responseType === 'blob', 'ignored outer assignment preserves conversion side effects'); + const marker = {}; + let caught; + try { xhr.responseType = { toString() { throw marker; } }; } catch (error) { caught = error; } + assert(caught === marker && xhr.responseType === 'blob', 'preserve the original conversion exception'); + try { xhr.responseType = Symbol('type'); } catch (error) { caught = error; } + assert(caught instanceof TypeError && xhr.responseType === 'blob', 'Symbols still throw'); + + const sync = new XMLHttpRequest(); + sync.open('GET', 'http://example.test/response', false); + for (const invalid of invalidValues) sync.responseType = invalid; + assert(sync.responseType === '', 'invalid values bypass synchronous Window restrictions'); + caught = undefined; + try { sync.responseType = 'json'; } catch (error) { caught = error; } + if (worker) assert(!caught && sync.responseType === 'json', 'sync Worker accepts json'); + else assert(caught instanceof DOMException && caught.name === 'InvalidAccessError', 'sync Window rejects valid values'); + return 'ok'; +})() +"#; + +fn start_response_type_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.__responseTypeResult = 'pending'; +const worker = new Worker(URL.createObjectURL(new Blob([{}], {{type: 'text/javascript'}}))); +worker.onmessage = event => {{ globalThis.__responseTypeResult = event.data; }}; +worker.onerror = event => {{ globalThis.__responseTypeResult = event.message; event.preventDefault(); }}; +'started' +"#, + serde_json::to_string(&script).unwrap() + ) + } else { + format!( + r#" +globalThis.__responseTypeResult = 'pending'; +Promise.resolve().then(() => {probe}).then( + value => {{ globalThis.__responseTypeResult = value; }}, + error => {{ globalThis.__responseTypeResult = String(error.stack || error); }} +); +'started' +"# + ) + }; + assert_eq!( + vm.eval(&source).expect("start responseType probe"), + "started" + ); +} + +#[test] +fn window_xhr_response_type_ignores_invalid_enum_assignments() { + let mut vm = new_storage_test_vm("https://xhr-response-type.test/"); + assert_eq!(vm.eval(CONVERSION_PROBE).unwrap(), "ok"); +} + +#[tokio::test(flavor = "current_thread")] +async fn worker_xhr_response_type_ignores_invalid_enum_assignments() { + let loader = static_http_loader(std::iter::empty::()); + let mut vm = + new_page_task_executor_test_vm_with_loader("https://xhr-response-type.test/", &loader); + start_response_type_probe(&mut vm, CONVERSION_PROBE, true); + advance_page_task_executor_until_eval_equals( + &mut vm, + &loader, + "globalThis.__responseTypeResult", + "ok", + "Worker enum setter", + ) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn xhr_response_type_checks_state_after_conversion_and_preserves_delivery() { + for worker in [false, true] { + let server = + StaticHttpServer::spawn_with_bodies(vec![r#"{"value":7}"#.to_owned(); 3]).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); }; + const worker = typeof document === 'undefined'; + const xhr = new XMLHttpRequest(); + const states = []; + xhr.responseType = 'json'; + const check = () => { + const before = xhr.response; + const type = xhr.responseType; + const count = states.length; + for (const value of ['JSON', 'moz-blob', 'moz-chunked-text', 'moz-chunked-arraybuffer', undefined]) { + xhr.responseType = value; + assert(xhr.responseType === type && xhr.response === before, 'ignore without resetting the response'); + } + if (worker) { + xhr.responseType = 'document'; + assert(xhr.responseType === type && xhr.response === before, 'Worker ignores document in every state'); + } + assert(states.length === count, 'ignored assignments dispatch no state events'); + if (xhr.readyState >= 3) { + let conversions = 0, caught; + try { xhr.responseType = { toString() { ++conversions; return 'text'; } }; } + catch (error) { caught = error; } + assert(conversions === 1 && caught instanceof DOMException && caught.name === 'InvalidStateError', + 'convert before rejecting a valid value in LOADING or DONE'); + caught = undefined; + try { xhr.responseType = Symbol(); } catch (error) { caught = error; } + assert(caught instanceof TypeError, 'conversion TypeError precedes the state error'); + const marker = {}; + try { xhr.responseType = { toString() { throw marker; } }; } catch (error) { caught = error; } + assert(caught === marker, 'conversion exceptions precede the state error'); + } + }; + const complete = new Promise((resolve, reject) => { + xhr.onreadystatechange = () => { + try { + states.push(xhr.readyState); + check(); + if (xhr.readyState === 4) resolve(); + } catch (error) { reject(error); } + }; + xhr.onerror = () => reject(new Error('request failed')); + }); + xhr.open('GET', REQUEST_URL + 'async'); + xhr.send(); + await complete; + assert(states.join(',') === '1,2,3,4', 'observe each request state'); + assert(xhr.status === 200 && xhr.response.value === 7, 'keep the parsed JSON response'); + xhr.onreadystatechange = null; + xhr.responseType = { toString() { xhr.open('GET', REQUEST_URL + 'reopened'); return 'text'; } }; + assert(xhr.readyState === 1 && xhr.responseType === 'text', 'reopening during conversion permits the assignment'); + + for (const value of ['invalid', 'text']) { + const sync = new XMLHttpRequest(); + sync.open('GET', REQUEST_URL + value, false); + let conversions = 0, caught; + try { + sync.responseType = { toString() { ++conversions; sync.send(); return value; } }; + } catch (error) { caught = error; } + assert(conversions === 1 && sync.readyState === 4, 'conversion may finish a synchronous request'); + if (value === 'invalid') assert(!caught, 'ignore invalid enum before checking the new state'); + else assert(caught instanceof DOMException && caught.name === 'InvalidStateError', 'check the state after conversion'); + assert(sync.responseType === '' && sync.responseText === '{"value":7}', 'preserve the completed response'); + } + return 'ok'; +})() +"#.replace("REQUEST_URL", &serde_json::to_string(url.as_str()).unwrap()); + start_response_type_probe(&mut vm, &probe, worker); + advance_page_task_executor_until_eval_equals( + &mut vm, + &loader, + "globalThis.__responseTypeResult", + "ok", + "responseType delivery and reentrancy", + ) + .await; + let requests = server.finish().await; + assert_eq!( + requests + .iter() + .map(|request| request.target.as_str()) + .collect::>(), + ["/async", "/invalid", "/text"] + ); + } +} + +#[test] +fn xhr_response_type_accessors_validate_native_receivers_in_the_accessor_realm() { + let mut vm = new_parsed_test_vm( + "https://xhr-response-type-realms.test/", + "", + ); + assert_eq!(vm.eval(r#" +(() => { + const assert = (value, message) => { if (!value) throw new Error(message); }; + const frame = document.body.appendChild(document.createElement('iframe')); + const owners = [window, frame.contentWindow]; + for (const owner of owners) { + const {get, set} = Object.getOwnPropertyDescriptor(owner.XMLHttpRequest.prototype, 'responseType'); + for (const receiver of [null, undefined, {}, owner.XMLHttpRequest.prototype, + Object.create(owner.XMLHttpRequest.prototype), Object.create(new owner.XMLHttpRequest()), + new Proxy(new owner.XMLHttpRequest(), {}), {__lmXhrResponseType: 'text'}]) { + let conversions = 0, caught; + try { set.call(receiver, {toString() { ++conversions; return 'invalid'; }}); } + catch (error) { caught = error; } + assert(caught instanceof owner.TypeError && conversions === 0, 'validate receiver before conversion in setter realm'); + caught = undefined; + try { get.call(receiver); } catch (error) { caught = error; } + assert(caught instanceof owner.TypeError, 'getter rejects forged receivers in its realm'); + } + for (const receiverOwner of owners) { + const xhr = new receiverOwner.XMLHttpRequest(); + set.call(xhr, 'json'); + assert(get.call(xhr) === 'json', 'native receivers work across realms'); + let caught; + try { set.call(xhr, Symbol()); } catch (error) { caught = error; } + assert(caught instanceof owner.TypeError, 'conversion exception uses the setter realm'); + } + class DerivedRequest extends owner.XMLHttpRequest {} + const derived = new DerivedRequest(); + Object.setPrototypeOf(derived, null); + set.call(derived, 'blob'); + assert(get.call(derived) === 'blob', 'native subclass identity survives prototype changes'); + } + return 'ok'; +})() +"#).unwrap(), "ok"); +} 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 d8ae3fb058..e89ef51a7d 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 @@ -356,6 +356,7 @@ fn xml_http_request_accessors_keep_instance_surface_clean() { let invalid = 'missing'; try { xhr.responseType = 'JSON'; + invalid = `ignored:${xhr.responseType}`; } catch (error) { invalid = `${error && error.name}:${xhr.responseType}`; } @@ -383,7 +384,7 @@ fn xml_http_request_accessors_keep_instance_surface_clean() { assert_eq!( result, - "true|json|TypeError:json|250|true|function|function|0|0|true|true" + "true|json|ignored:json|250|true|function|function|0|0|true|true" ); } @@ -621,10 +622,13 @@ fn xml_http_request_declared_state_slots_ignore_reflection_and_spoofing() { fake.__lmXhrTimeout = 888; fake.__lmXhrWithCredentials = true; + let fakeResponseType; + try { fakeResponseType = fake.responseType; } + catch (error) { fakeResponseType = error.name; } const fakeValues = [ fake.readyState, fake.status, - fake.responseType, + fakeResponseType, fake.timeout, fake.withCredentials, fake.upload === undefined ? 'undefined' : typeof fake.upload @@ -652,7 +656,7 @@ fn xml_http_request_declared_state_slots_ignore_reflection_and_spoofing() { assert_eq!( result, - r#"{"ownNamesBefore":{"xhr":[],"upload":[],"xhrPrototype":[],"eventTargetPrototype":[]},"realValues":"1|0|json|250|true|function|function|1","fakeValues":"0|0||0|false|undefined"}"# + r#"{"ownNamesBefore":{"xhr":[],"upload":[],"xhrPrototype":[],"eventTargetPrototype":[]},"realValues":"1|0|json|250|true|function|function|1","fakeValues":"0|0|TypeError|0|false|undefined"}"# ); } @@ -2084,7 +2088,7 @@ fn xml_http_request_accessors_apply_webidl_conversion() { assert_eq!( result, - "4294967295|throw:TypeError|public|throw:RangeError|json:1|throw:TypeError||true:0|true" + "4294967295|throw:TypeError|public|throw:RangeError|json:1|throw:TypeError|json|true:0|true" ); } #[test] diff --git a/moli-wpt-compat/fixtures/wpt/manifest.toml b/moli-wpt-compat/fixtures/wpt/manifest.toml index e9fc849f0f..f9c297dcff 100644 --- a/moli-wpt-compat/fixtures/wpt/manifest.toml +++ b/moli-wpt-compat/fixtures/wpt/manifest.toml @@ -11659,7 +11659,7 @@ wait_until = "load" timeout_ms = 5000 suite = "smoke" tags = ["worker", "xhr", "xmlhttprequest"] -notes = "Manual smoke port for dedicated worker XMLHttpRequest responseType=json, responseType=arraybuffer, and responseType=blob using the existing focused fixture routes, responseText InvalidStateError for non-text responseType values, Window-only responseXML exposure, responseType LOADING/DONE InvalidStateError preservation, plus worker-specific responseType=document ignore semantics and invalid responseType TypeError preservation." +notes = "Manual smoke port for dedicated worker XMLHttpRequest responseType=json, responseType=arraybuffer, and responseType=blob using the existing focused fixture routes, responseText InvalidStateError for non-text responseType values, Window-only responseXML exposure, responseType LOADING/DONE InvalidStateError preservation, plus worker-specific responseType=document ignore semantics and invalid responseType assignments preserving the previous value without throwing." [[test]] id = "worker-xhr-redirect-error-basic" diff --git a/moli-wpt-compat/fixtures/wpt/ported/worker/worker-xhr-responsetype-basic.html b/moli-wpt-compat/fixtures/wpt/ported/worker/worker-xhr-responsetype-basic.html index 06f1e0bd2b..b0d856cbbe 100644 --- a/moli-wpt-compat/fixtures/wpt/ported/worker/worker-xhr-responsetype-basic.html +++ b/moli-wpt-compat/fixtures/wpt/ported/worker/worker-xhr-responsetype-basic.html @@ -108,7 +108,7 @@ promise_test(async function () { promise_test(async function () { const result = await run_mode("invalid-response-type"); - assert_equals(result.errorName, "TypeError", "invalid responseType should throw in a worker"); + assert_equals(result.errorName, null, "invalid responseType should be ignored in a worker"); assert_equals(result.responseType, "arraybuffer", "invalid responseType should not overwrite the previous value in a worker"); -}, "Dedicated workers enforce XMLHttpRequest responseType enum conversion"); +}, "Dedicated workers ignore invalid XMLHttpRequest responseType values");