mirror of
https://github.com/lexmount/moli.git
synced 2026-10-05 00:00:55 +00:00
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.
This commit is contained in:
@@ -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::<XhrResponseTypeArgs>(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::<XhrResponseTypeArgs>(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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Self> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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::<String>());
|
||||
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::<String>());
|
||||
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::<Vec<_>>(),
|
||||
["/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/",
|
||||
"<!doctype html><html><body></body></html>",
|
||||
);
|
||||
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");
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user