fix(xhr): serialize document bodies and normalize text charsets

This commit is contained in:
ldm0
2026-09-27 06:43:22 +08:00
parent f524de0e2f
commit 63ea2024cd
7 changed files with 229 additions and 22 deletions
Generated
+1
View File
@@ -3094,6 +3094,7 @@ dependencies = [
"moli-broadcast-channel",
"moli-browser-profile",
"moli-canvas",
"moli-content-type",
"moli-cookie-jar",
"moli-crypto",
"moli-css-parse",
+1
View File
@@ -36,6 +36,7 @@ stylo_static_prefs = "0.20"
moli-action-window = { path = "../moli-action-window" }
moli-broadcast-channel = { path = "../moli-broadcast-channel" }
moli-browser-profile = { path = "../moli-browser-profile" }
moli-content-type = { path = "../moli-content-type" }
moli-crypto = { path = "../moli-crypto" }
moli-css-parse = { path = "../moli-css-parse" }
moli-dom = { path = "../moli-dom" }
+1 -1
View File
@@ -40,7 +40,7 @@ pub(super) use self::bindings::install_window_network_bindings;
pub(in crate::network_host) use self::body::{
PreparedBodyInit, body_init, body_is_unusable, body_is_used, readable_body_stream_unusable,
};
pub(crate) use self::body::{append_default_body_content_type, body_stream_object, has_header};
pub(crate) use self::body::{append_default_body_content_type, body_stream_object};
#[cfg(test)]
pub(crate) use self::body_source::pending_network_body_source_buffered_len_for_test;
pub(in crate::network_host) use self::body_source::{
@@ -123,9 +123,3 @@ pub(crate) fn append_default_body_content_type(
}
headers.push(("Content-Type".to_owned(), content_type.to_owned()));
}
pub(crate) fn has_header(headers: &[(String, String)], name: &str) -> bool {
headers
.iter()
.any(|(header_name, _)| header_name.eq_ignore_ascii_case(name))
}
@@ -82,7 +82,7 @@ pub(super) fn prepare_xhr_send_request<'s>(
let resolved_url =
resolve_context_url(&base_url, &url_str, None).map_err(XhrSendPrepareError::Url)?;
let (request_headers, cors_preflight_request_headers) =
xhr_request_headers(scope, host, xhr, prepared_body.default_content_type);
xhr_request_headers(scope, host, xhr, &prepared_body);
let credentials_mode =
if xhr_state_bool_property(scope, xhr, XHR_WITH_CREDENTIALS_SLOT).unwrap_or(false) {
moli_fetch::RequestCredentialsMode::Include
@@ -111,6 +111,7 @@ pub(super) fn prepare_xhr_send_request<'s>(
pub(crate) struct PreparedXhrSendBody {
pub(crate) body: Option<Vec<u8>>,
pub(crate) default_content_type: Option<String>,
rewrite_content_type_charset: bool,
}
pub(crate) enum ConvertedXhrSendBody<'s> {
@@ -130,9 +131,9 @@ impl<'s> ConvertedXhrSendBody<'s> {
}
match self {
Self::Native(value) => prepare_xhr_send_body(scope, value),
Self::Text(text) => Ok(PreparedXhrSendBody::new(
Self::Text(text) => Ok(PreparedXhrSendBody::utf8_text(
text.into_bytes(),
Some(TEXT_CONTENT_TYPE.to_owned()),
TEXT_CONTENT_TYPE,
)),
}
}
@@ -143,6 +144,7 @@ impl PreparedXhrSendBody {
Self {
body: None,
default_content_type: None,
rewrite_content_type_charset: false,
}
}
@@ -150,6 +152,15 @@ impl PreparedXhrSendBody {
Self {
body: Some(body),
default_content_type,
rewrite_content_type_charset: false,
}
}
fn utf8_text(body: Vec<u8>, content_type: &str) -> Self {
Self {
body: Some(body),
default_content_type: Some(content_type.to_owned()),
rewrite_content_type_charset: true,
}
}
}
@@ -163,6 +174,9 @@ pub(crate) fn prepare_xhr_send_body<'s>(
}
if let Ok(object) = v8::Local::<v8::Object>::try_from(value) {
if let Some(body) = prepare_xhr_document_body(scope, object) {
return Ok(body);
}
if let Some((body, content_type)) =
crate::context_bootstrap::form_data_request_body(scope, object)
{
@@ -170,9 +184,9 @@ pub(crate) fn prepare_xhr_send_body<'s>(
}
if let Some(body) = crate::context_bootstrap::url_search_params_request_body(scope, object)
{
return Ok(PreparedXhrSendBody::new(
return Ok(PreparedXhrSendBody::utf8_text(
body.into_bytes(),
Some(URL_SEARCH_PARAMS_CONTENT_TYPE.to_owned()),
URL_SEARCH_PARAMS_CONTENT_TYPE,
));
}
if let Some(bytes) = blob::blob_bytes_from_object(scope, object) {
@@ -193,9 +207,39 @@ pub(crate) fn prepare_xhr_send_body<'s>(
value,
crate::webidl::Context::argument("XMLHttpRequest.send", 1),
)?;
Ok(PreparedXhrSendBody::new(
Ok(PreparedXhrSendBody::utf8_text(
body.0.into_bytes(),
Some(TEXT_CONTENT_TYPE.to_owned()),
TEXT_CONTENT_TYPE,
))
}
fn prepare_xhr_document_body<'s>(
scope: &mut v8::PinScope<'s, '_>,
object: v8::Local<'s, v8::Object>,
) -> Option<PreparedXhrSendBody> {
let (runtime_ptr, handle) =
crate::native_bridge::node_runtime_and_handle_from_object_or_detached(scope, object)
.ok()?;
// SAFETY: the node bridge supplies the live callback's context host. Neither
// serialization nor document classification calls author JavaScript.
let runtime = unsafe { &*runtime_ptr };
let dom_host = runtime.dom_host();
let document = dom_host.node(handle)?.as_document()?;
let (body, content_type) = if document.is_html_document() {
let scripting_enabled = |node| runtime.node_document_scripting_enabled(node);
(
dom_host.get_html(handle, &scripting_enabled, false, &[])?,
"text/html;charset=UTF-8",
)
} else {
(
crate::xml_serializer::serialize_native_handle(dom_host, handle),
"application/xml;charset=UTF-8",
)
};
Some(PreparedXhrSendBody::utf8_text(
body.into_bytes(),
content_type,
))
}
@@ -242,9 +286,9 @@ fn xhr_request_headers(
scope: &mut v8::PinScope<'_, '_>,
host: &JsContextHost,
xhr: v8::Local<'_, v8::Object>,
default_content_type: Option<String>,
prepared_body: &PreparedXhrSendBody,
) -> (moli_fetch::RequestHeaders, Vec<(String, String)>) {
let author_headers = xhr_author_request_headers(scope, xhr, default_content_type);
let author_headers = xhr_author_request_headers(scope, xhr, prepared_body);
let merged = merge_byte_string_request_headers(host.extra_http_headers(), &author_headers);
(merged, author_headers)
}
@@ -252,7 +296,7 @@ fn xhr_request_headers(
pub(crate) fn xhr_author_request_headers(
scope: &mut v8::PinScope<'_, '_>,
xhr: v8::Local<'_, v8::Object>,
default_content_type: Option<String>,
prepared_body: &PreparedXhrSendBody,
) -> Vec<(String, String)> {
let headers_json = xhr_state_string_property(scope, xhr, XHR_REQUEST_HEADERS_SLOT)
.unwrap_or_else(|| "[]".to_owned());
@@ -261,10 +305,82 @@ pub(crate) fn xhr_author_request_headers(
.into_iter()
.map(|[name, value]| (name, value))
.collect();
if let Some(default_content_type) = default_content_type
&& !has_header(&author_headers, CONTENT_TYPE_HEADER)
if let Some((_, value)) = author_headers
.iter_mut()
.find(|(name, _)| name.eq_ignore_ascii_case(CONTENT_TYPE_HEADER))
{
author_headers.push((CONTENT_TYPE_HEADER.to_owned(), default_content_type));
if prepared_body.rewrite_content_type_charset
&& let Some(rewritten) = xhr_content_type_with_utf8_charset(value)
{
*value = rewritten;
}
} else if let Some(default_content_type) = &prepared_body.default_content_type {
author_headers.push((CONTENT_TYPE_HEADER.to_owned(), default_content_type.clone()));
}
author_headers
}
fn xhr_content_type_with_utf8_charset(original: &str) -> Option<String> {
let mut mime = moli_content_type::parse_mime_type(original)?;
let charset = mime.parameter_mut("charset")?;
if charset.eq_ignore_ascii_case("UTF-8") {
return None;
}
*charset = "UTF-8".to_owned();
Some(mime.to_string())
}
#[cfg(test)]
mod tests {
use super::xhr_content_type_with_utf8_charset;
#[test]
fn xhr_charset_rewriting_uses_mime_parameter_parsing_and_serialization() {
for (input, expected) in [
("", None),
("text; charset=ascii", None),
("text/plain", None),
("text/plain; hi=bye", None),
("text/plain; charset =ascii", None),
("text/plain;charset=utf-8;charset=ascii", None),
(r#"Text/Plain; CHARSET="uTf-8"; KEEP=Value"#, None),
(r#"text/plain;charset="u\t\f-8""#, None),
(r#"text/plain;boundary="; charset=ascii""#, None),
("text/plain;charset=utf-8 ;x=x", None),
(
"text/plain;charset= utf-8",
Some("text/plain;charset=UTF-8"),
),
(
"text/plain;charset=;charset=ascii",
Some("text/plain;charset=UTF-8"),
),
(
r#"text/plain;charset="";charset=utf-8"#,
Some("text/plain;charset=UTF-8"),
),
(
"text/plain;charset='utf-8'",
Some("text/plain;charset=UTF-8"),
),
(
r#"text/plain;charset="ASCII"#,
Some("text/plain;charset=UTF-8"),
),
(
"text/x-pink-unicorn; charset=windows-1252; charset=bogus; notrelated; charset=ascii",
Some("text/x-pink-unicorn;charset=UTF-8"),
),
(
"YO/yo;charset=x;yo=YO; X=y",
Some("yo/yo;charset=UTF-8;yo=YO;x=y"),
),
] {
assert_eq!(
xhr_content_type_with_utf8_charset(input).as_deref(),
expected,
"{input}"
);
}
}
}
@@ -1152,6 +1152,102 @@ fn xml_http_request_default_response_type_parses_response_xml_for_document_mime(
assert_eq!(result, "4|true|html|true|false|true");
}
#[test]
fn xml_http_request_serializes_document_bodies_and_limits_charset_rewriting_to_text() {
let vm = new_storage_test_vm("https://xhr-document-body.test/");
let context_ptr: *const v8::Global<v8::Context> = &vm.page_default_context as *const _;
vm.renderer_document_isolate
.with_entered_renderer_document_isolate(move |isolate| {
let scope = std::pin::pin!(v8::HandleScope::new(isolate));
let scope = &mut scope.init();
let context = unsafe { v8::Local::new(scope, &*context_ptr) };
let scope = &mut v8::ContextScope::new(scope, context);
let source = v8::String::new(
scope,
r#"(() => {
const xml = document.implementation.createDocument('urn:test', 'root');
xml.documentElement.textContent = 'caf\u00e9\ud800';
const html = document.implementation.createHTMLDocument();
html.body.innerHTML = '<p>caf\u00e9 &amp;<br></p><template><b>x</b></template>';
const empty = document.implementation.createDocument(null, null);
const bogus = document.implementation.createDocument(null, null);
const element = bogus.createElement('test:test');
element.setAttribute('x', '\ud800');
bogus.appendChild(element);
const xhtml = new DOMParser().parseFromString('<html xmlns="http://www.w3.org/1999/xhtml"><br/></html>', 'application/xhtml+xml');
for (const doc of [xml, html, empty, bogus, xhtml]) {
Object.defineProperties(doc, {
toString: {value() { throw new Error('Document toString'); }},
contentType: {get() { throw new Error('Document contentType'); }},
nodeType: {get() { throw new Error('Document nodeType'); }},
});
}
const ordinaryElement = document.createElement('div');
ordinaryElement.toString = () => 'element text';
const spoof = {nodeType: 9, toString() { return 'ordinary text'; }};
const form = new FormData();
const xhr = new XMLHttpRequest();
xhr.open('POST', '/');
xhr.setRequestHeader('Content-Type', 'Text/Plain; Charset=ASCII;keep="alpha;beta"');
return [xhr, [
[xml, '<root xmlns="urn:test">caf\u00e9\ufffd</root>', 'application/xml;charset=UTF-8', true],
[html, '<!DOCTYPE html><html><head></head><body><p>caf\u00e9 &amp;<br></p><template><b>x</b></template></body></html>', 'text/html;charset=UTF-8', true],
[empty, '', 'application/xml;charset=UTF-8', true],
[bogus, '<test:test x="\ufffd"/>', 'application/xml;charset=UTF-8', true],
[xhtml, '<html xmlns="http://www.w3.org/1999/xhtml"><br /></html>', 'application/xml;charset=UTF-8', true],
[ordinaryElement, 'element text', 'text/plain;charset=UTF-8', true],
[spoof, 'ordinary text', 'text/plain;charset=UTF-8', true],
['caf\u00e9\ud800', 'caf\u00e9\ufffd', 'text/plain;charset=UTF-8', true],
['', '', 'text/plain;charset=UTF-8', true],
[new URLSearchParams({q: 'caf\u00e9'}), 'q=caf%C3%A9', 'application/x-www-form-urlencoded;charset=UTF-8', true],
[new Blob(['bytes'], {type: 'text/plain;charset=ascii'}), 'bytes', 'text/plain;charset=ascii', false],
[new Uint8Array([65, 66]), 'AB', null, false],
[null, null, null, false],
[form, undefined, undefined, false],
]];
})()"#,
)
.expect("body fixture source");
let script = v8::Script::compile(scope, source, None).expect("compile body fixtures");
let fixtures = script.run(scope).expect("create body fixtures");
let fixtures = v8::Local::<v8::Array>::try_from(fixtures).expect("body fixture array");
let xhr_value = fixtures.get_index(scope, 0).expect("xhr fixture");
let xhr = v8::Local::<v8::Object>::try_from(xhr_value).expect("xhr object");
let cases_value = fixtures.get_index(scope, 1).expect("body cases");
let cases = v8::Local::<v8::Array>::try_from(cases_value).expect("body cases array");
for index in 0..cases.length() {
let case = cases.get_index(scope, index).expect("body case");
let case = v8::Local::<v8::Array>::try_from(case).expect("body case array");
let body = case.get_index(scope, 0).expect("body value");
let prepared = crate::network_host::prepare_xhr_send_body(scope, body)
.expect("prepare body without observing Document properties");
let expected_bytes = case.get_index(scope, 1).expect("expected bytes");
let expected_type = case.get_index(scope, 2).expect("expected type");
let rewrite = case.get_index(scope, 3).expect("charset policy").is_true();
if expected_bytes.is_null() {
assert!(prepared.body.is_none());
} else if !expected_bytes.is_undefined() {
let expected = expected_bytes.to_string(scope).unwrap().to_rust_string_lossy(scope);
assert_eq!(prepared.body.as_deref(), Some(expected.as_bytes()), "case {index}");
}
if expected_type.is_null() {
assert!(prepared.default_content_type.is_none());
} else if !expected_type.is_undefined() {
let expected = expected_type.to_string(scope).unwrap().to_rust_string_lossy(scope);
assert_eq!(prepared.default_content_type.as_deref(), Some(expected.as_str()), "case {index}");
}
let headers = crate::network_host::xhr_author_request_headers(scope, xhr, &prepared);
assert_eq!(headers, [("Content-Type".to_owned(), if rewrite {
"text/plain;charset=UTF-8;keep=\"alpha;beta\"".to_owned()
} else {
"Text/Plain; Charset=ASCII;keep=\"alpha;beta\"".to_owned()
})], "case {index}");
}
Ok(())
})
.expect("document body preparation probe should run");
}
#[test]
fn xml_http_request_send_body_applies_webidl_conversion() {
let vm = new_storage_test_vm("https://xhr-send-body-webidl.test/");
@@ -1169,8 +1169,7 @@ pub(in crate::worker) fn prepare_worker_xhr_send_request<'s>(
.ok_or(WorkerXhrSendPrepareError::ScriptUrlUnavailable)?;
let resolved_url = resolve_context_url(&document_url, &url_str, None)
.map_err(WorkerXhrSendPrepareError::Url)?;
let request_headers =
xhr_author_request_headers(scope, xhr, prepared_body.default_content_type);
let request_headers = xhr_author_request_headers(scope, xhr, &prepared_body);
let credentials_mode =
if xhr_state_bool_property(scope, xhr, XHR_WITH_CREDENTIALS_SLOT).unwrap_or(false) {
RequestCredentialsMode::Include