diff --git a/Cargo.lock b/Cargo.lock index 7e3c6aa4f..65b2c269a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3094,6 +3094,7 @@ dependencies = [ "moli-broadcast-channel", "moli-browser-profile", "moli-canvas", + "moli-content-type", "moli-cookie-jar", "moli-crypto", "moli-css-parse", diff --git a/moli-renderer-v8/Cargo.toml b/moli-renderer-v8/Cargo.toml index b925ec44d..feb227dbe 100644 --- a/moli-renderer-v8/Cargo.toml +++ b/moli-renderer-v8/Cargo.toml @@ -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" } diff --git a/moli-renderer-v8/src/network_host.rs b/moli-renderer-v8/src/network_host.rs index fd4743390..da8496a31 100644 --- a/moli-renderer-v8/src/network_host.rs +++ b/moli-renderer-v8/src/network_host.rs @@ -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::{ diff --git a/moli-renderer-v8/src/network_host/body.rs b/moli-renderer-v8/src/network_host/body.rs index 8021e4724..2c6b29f34 100644 --- a/moli-renderer-v8/src/network_host/body.rs +++ b/moli-renderer-v8/src/network_host/body.rs @@ -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)) -} diff --git a/moli-renderer-v8/src/network_host/xhr/send/request.rs b/moli-renderer-v8/src/network_host/xhr/send/request.rs index 49f987c74..ff5c10b73 100644 --- a/moli-renderer-v8/src/network_host/xhr/send/request.rs +++ b/moli-renderer-v8/src/network_host/xhr/send/request.rs @@ -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>, pub(crate) default_content_type: Option, + 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, 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::::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 { + 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, + 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, + 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 { + 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}" + ); + } + } +} 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 17e04525e..989014e4a 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 @@ -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 = &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 = '

caf\u00e9 &

'; + 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('
', '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, 'caf\u00e9\ufffd', 'application/xml;charset=UTF-8', true], + [html, '

caf\u00e9 &

', 'text/html;charset=UTF-8', true], + [empty, '', 'application/xml;charset=UTF-8', true], + [bogus, '', 'application/xml;charset=UTF-8', true], + [xhtml, '
', '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::::try_from(fixtures).expect("body fixture array"); + let xhr_value = fixtures.get_index(scope, 0).expect("xhr fixture"); + let xhr = v8::Local::::try_from(xhr_value).expect("xhr object"); + let cases_value = fixtures.get_index(scope, 1).expect("body cases"); + let cases = v8::Local::::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::::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/"); diff --git a/moli-renderer-v8/src/worker/global_scope/xhr.rs b/moli-renderer-v8/src/worker/global_scope/xhr.rs index ef6962418..d549d168c 100644 --- a/moli-renderer-v8/src/worker/global_scope/xhr.rs +++ b/moli-renderer-v8/src/worker/global_scope/xhr.rs @@ -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