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");