From 112a97e61c2e7b21851d01579335f6652bc2c197 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Tue, 29 Sep 2026 20:56:44 +0800 Subject: [PATCH] fix(mime): unify JSON/font types and response header extraction Accept text/json for JSON modules and use one is_json_mime classifier across modules, documents, JSONPath, and resource inspection. Remove the module and document aliases and recognize the seven registered application font types. Use Fetch MIME extraction over the complete Content-Type header list for script/style checks, workers, WebAssembly, ORB, and JSONPath. Derive classic script charsets from the same MIME record, including charset inheritance and resets when the MIME essence changes, while preserving BOM and fallback order. Cover valid and invalid MIME types, header ordering, combined fields, quoted commas, nosniff, and charset selection with regression tests. Validation: - cargo fmt --all - cargo clippy --workspace --all-targets --all-features -- -D warnings - cargo nextest run --no-fail-fast: 19,005 passed, 16 skipped - Focused WPT: Moli passes 9 cases / 261 subtests, including the three JSON regressions and all 31 script Content-Type extraction checks. --- Cargo.lock | 1 + moli-encoding/Cargo.toml | 1 + moli-encoding/src/script.rs | 7 +- moli-encoding/src/tests.rs | 58 +++++ moli-page-types/src/lib.rs | 47 ++-- .../src/domains/page/resource_search.rs | 4 +- .../src/document_response_decoder.rs | 4 +- moli-renderer-v8/src/module_runtime/graph.rs | 26 ++- .../src/module_runtime/response_mime.rs | 4 +- .../context_host/child_frames/discovery.rs | 2 +- moli-renderer-v8/src/stylesheet_blocking.rs | 31 +++ moli-renderer-v8/src/worker/module_mime.rs | 10 +- moli-renderer-v8/src/worker/script_mime.rs | 37 +++- .../src/worker/thread/tests/modules.rs | 4 + moli-web-mime/src/classification.rs | 26 ++- moli-web-mime/src/lib.rs | 2 +- moli-web-mime/src/response_policy.rs | 55 ++--- moli-web-mime/src/tests.rs | 206 +++++++++++++++++- 18 files changed, 423 insertions(+), 102 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 85928ba0ca..79dcf5c65a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2647,6 +2647,7 @@ dependencies = [ "moli-charset-parser", "moli-content-type", "moli-header-field", + "moli-web-mime", ] [[package]] diff --git a/moli-encoding/Cargo.toml b/moli-encoding/Cargo.toml index 4882e295d5..c3c7dcf9af 100644 --- a/moli-encoding/Cargo.toml +++ b/moli-encoding/Cargo.toml @@ -9,6 +9,7 @@ moli-header-field = { path = "../moli-header-field" } encoding_rs = "0.8" moli-charset-parser = { path = "../moli-charset-parser" } moli-content-type = { path = "../moli-content-type" } +moli-web-mime = { path = "../moli-web-mime" } [lints] workspace = true diff --git a/moli-encoding/src/script.rs b/moli-encoding/src/script.rs index 406cc748be..d3bd7889df 100644 --- a/moli-encoding/src/script.rs +++ b/moli-encoding/src/script.rs @@ -1,6 +1,6 @@ use encoding_rs::Encoding; -use crate::{encoding_for_label, encoding_from_response_headers}; +use crate::encoding_for_label; pub fn decode_utf8(bytes: &[u8]) -> String { encoding_rs::UTF_8 @@ -17,7 +17,10 @@ pub fn decode_classic_script_source( ) -> String { let encoding = Encoding::for_bom(bytes) .map(|(encoding, _)| encoding) - .or_else(|| encoding_from_response_headers(headers)) + .or_else(|| { + moli_web_mime::extract_response_mime_type(headers) + .and_then(|mime| mime.parameter("charset").and_then(encoding_for_label)) + }) .or_else(|| script_charset.and_then(encoding_for_label)) .or_else(|| document_character_set.and_then(encoding_for_label)) .unwrap_or(encoding_rs::UTF_8); diff --git a/moli-encoding/src/tests.rs b/moli-encoding/src/tests.rs index b4c0104e7a..d2621452e3 100644 --- a/moli-encoding/src/tests.rs +++ b/moli-encoding/src/tests.rs @@ -763,6 +763,64 @@ fn classic_script_header_charset_wins_over_document_character_set() { ); } +#[test] +fn classic_script_charset_uses_the_extracted_mime_record() { + for (values, expected) in [ + ( + vec!["text/plain; charset=windows-1252", "text/javascript"], + "€", + ), + ( + vec!["text/javascript; charset=windows-1252", "text/javascript"], + "€", + ), + ( + vec![ + "text/javascript; charset=windows-1252", + "invalid", + "text/javascript", + ], + "€", + ), + ( + vec![ + "text/javascript; charset=windows-1252", + "text/plain", + "text/javascript", + ], + "€", + ), + ( + vec![ + "text/javascript; charset=shift_jis", + "text/javascript; charset=windows-1252", + ], + "€", + ), + ( + vec!["text/javascript; charset=bogus", "text/javascript"], + "€", + ), + (vec!["text/javascript; charset=\" windows-1252 \""], "€"), + ] { + for combined in [false, true] { + let headers: Vec<_> = if combined { + vec![("Content-Type".to_owned(), values.join(", ").into_bytes())] + } else { + values + .iter() + .map(|value| ("Content-Type".to_owned(), value.as_bytes().to_vec())) + .collect() + }; + assert_eq!( + decode_classic_script_source("€".as_bytes(), &headers, None, Some("utf-8")), + expected, + "combined={combined}: {values:?}" + ); + } + } +} + #[test] fn classic_script_charset_attribute_is_fallback_before_document_character_set() { let script = r#"document.body.textContent = "目次";"#; diff --git a/moli-page-types/src/lib.rs b/moli-page-types/src/lib.rs index 035247ba0d..1b0451822e 100644 --- a/moli-page-types/src/lib.rs +++ b/moli-page-types/src/lib.rs @@ -36,7 +36,7 @@ use moli_fetch::{ NegotiatedHttpVersion, NetworkRequestExtraInfo, NetworkResponseExtraInfo, RedirectInfo, RequestAuth, RequestAuthScheme, RequestAuthTarget, Response, ResponseBody, ResponseHead, }; -use moli_web_mime::is_json_module_mime; +use moli_web_mime::{extract_response_mime_essence, is_json_mime}; const SUBRESOURCE_RESPONSE_BODY_MEMORY_LIMIT: usize = 1024 * 1024; @@ -1993,10 +1993,10 @@ fn json_path_satisfies( path: &[String], predicate: impl FnOnce(&Value) -> bool, ) -> bool { - let Some(content_type) = header_value(headers, "content-type") else { + let Some(content_type) = extract_response_mime_essence(headers) else { return false; }; - if !is_json_module_mime(&content_type) { + if !is_json_mime(&content_type) { return false; } @@ -2040,16 +2040,6 @@ fn json_value_matches_regex(value: &Value, regex: &Regex) -> bool { } } -fn header_value<'a>( - headers: &'a [(String, Vec)], - name: &str, -) -> Option> { - headers - .iter() - .find(|(header_name, _)| header_name.eq_ignore_ascii_case(name)) - .map(|(_, value)| moli_fetch::decode_header_value(value)) -} - impl SubresourceNetworkRecord { fn from_staged_lifecycle( request: &SubresourceRequestStarted, @@ -4343,11 +4333,32 @@ mod tests { r#"{"ok":true}"#, &expectation, )); - assert!(!json_path_equals( + assert!(json_path_equals( &[("content-type".to_owned(), b"text/json".to_vec())], r#"{"ok":true}"#, &expectation, )); + assert!(!json_path_equals( + &[("content-type".to_owned(), b"text/plain".to_vec())], + r#"{"ok":true}"#, + &expectation, + )); + for (values, matches) in [ + (vec!["text/plain", "text/json"], true), + (vec!["text/plain, text/json"], true), + (vec!["text/json", "invalid", "*/*"], true), + (vec!["text/json", "text/plain"], false), + ] { + let headers: Vec<_> = values + .iter() + .map(|value| ("Content-Type".to_owned(), value.as_bytes().to_vec())) + .collect(); + assert_eq!( + json_path_equals(&headers, r#"{"ok":true}"#, &expectation), + matches, + "{values:?}" + ); + } let regex_expectation = SubresourceJsonPathRegex { path: vec!["data".to_owned(), "url".to_owned()], @@ -4361,6 +4372,14 @@ mod tests { r#"{"data":{"url":"/item/42"}}"#, ®ex_expectation, )); + assert!(json_path_matches_regex( + &[( + "content-type".to_owned(), + b"Text/JSON; charset=utf-8".to_vec(), + )], + r#"{"data":{"url":"/item/42"}}"#, + ®ex_expectation, + )); assert!(!json_path_matches_regex( &[("content-type".to_owned(), b"application/json".to_vec())], r#"{"data":{"url":"/orders/42"}}"#, diff --git a/moli-protocol/src/domains/page/resource_search.rs b/moli-protocol/src/domains/page/resource_search.rs index 7d87eaf53f..767fc2fae9 100644 --- a/moli-protocol/src/domains/page/resource_search.rs +++ b/moli-protocol/src/domains/page/resource_search.rs @@ -10,7 +10,7 @@ use moli_encoding::{ }; use moli_web_mime::{ effective_response_mime_essence, is_dom_parser_xml_mime, is_html_document_mime, - is_javascript_mime_essence, is_json_module_mime, is_text_mime_essence, + is_javascript_mime_essence, is_json_mime, is_text_mime_essence, }; use serde::Deserialize; use serde_json::json; @@ -395,7 +395,7 @@ fn decode_resource_content( ) { return decode_text_for_legacy_web(bytes, response_charset); } - if is_dom_parser_xml_mime(&mime) || is_json_module_mime(&mime) { + if is_dom_parser_xml_mime(&mime) || is_json_mime(&mime) { return decode_text_for_legacy_web(bytes, response_charset); } if is_text_mime_essence(&mime) { diff --git a/moli-renderer-v8/src/document_response_decoder.rs b/moli-renderer-v8/src/document_response_decoder.rs index 4acc309b92..82cc35102e 100644 --- a/moli-renderer-v8/src/document_response_decoder.rs +++ b/moli-renderer-v8/src/document_response_decoder.rs @@ -1,6 +1,6 @@ use moli_encoding::HtmlDocumentStreamingDecoder; use moli_encoding_detector::detect_legacy_html_encoding; -use moli_web_mime::{is_json_document_mime, is_text_document_mime, is_xml_document_mime}; +use moli_web_mime::{is_json_mime, is_text_document_mime, is_xml_document_mime}; use url::Url; pub(crate) fn new_document_response_decoder( @@ -14,7 +14,7 @@ pub(crate) fn new_document_response_decoder( headers, final_url.as_str(), detect_legacy_html_encoding, - is_json_document_mime(mime), + is_json_mime(mime), inherited_encoding, ) } else if content_type.is_some_and(is_xml_document_mime) { diff --git a/moli-renderer-v8/src/module_runtime/graph.rs b/moli-renderer-v8/src/module_runtime/graph.rs index 0e57c1fab3..e376f17024 100644 --- a/moli-renderer-v8/src/module_runtime/graph.rs +++ b/moli-renderer-v8/src/module_runtime/graph.rs @@ -3957,10 +3957,21 @@ import "./c.mjs"; } #[tokio::test(flavor = "multi_thread")] - async fn module_graph_fetch_enforces_json_and_css_response_mime() -> anyhow::Result<()> { + async fn module_graph_fetch_enforces_response_mime_for_module_kinds() -> anyhow::Result<()> { for (kind, mime, body) in [ + ( + ModuleKind::JavaScript, + "text/javascript", + "export const answer = 42;", + ), (ModuleKind::Json, "application/json", r#"{"answer":42}"#), + (ModuleKind::Json, "text/json", r#"{"answer":42}"#), (ModuleKind::Css, "text/css", "#test { color: red; }"), + ( + ModuleKind::WebAssembly, + "application/wasm", + "\0asm\x01\0\0\0", + ), ] { let cases = [ (vec![mime.to_owned()], true), @@ -4018,10 +4029,15 @@ import "./c.mjs"; server.await?; assert_eq!(result.is_ok(), accepts, "{kind:?}: {values:?}: {result:?}"); if let Err(error) = result { - let expected = if kind == ModuleKind::Json { - "non-JSON module response" - } else { - "non-CSS module response" + let expected = match kind { + ModuleKind::JavaScript | ModuleKind::WebAssembly => { + "unsupported script MIME type" + } + ModuleKind::Json => "non-JSON module response", + ModuleKind::Css => "non-CSS module response", + ModuleKind::ModulePreloadText => { + unreachable!("text preloads do not enforce a response MIME type") + } }; assert!(error.to_string().contains(expected), "{error:#}"); } diff --git a/moli-renderer-v8/src/module_runtime/response_mime.rs b/moli-renderer-v8/src/module_runtime/response_mime.rs index ab3422e4de..5d78fffcee 100644 --- a/moli-renderer-v8/src/module_runtime/response_mime.rs +++ b/moli-renderer-v8/src/module_runtime/response_mime.rs @@ -1,9 +1,9 @@ -use moli_web_mime::{extract_response_mime_essence, is_css_mime, is_json_module_mime}; +use moli_web_mime::{extract_response_mime_essence, is_css_mime, is_json_mime}; pub(crate) fn validate_json_module_response_mime( headers: &[(String, Vec)], ) -> Result<(), String> { - validate_module_response_mime(headers, "JSON", is_json_module_mime) + validate_module_response_mime(headers, "JSON", is_json_mime) } pub(crate) fn validate_css_module_response_mime( diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_frames/discovery.rs b/moli-renderer-v8/src/native_bridge/context_host/child_frames/discovery.rs index 1a84f1a04f..a53f44811e 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/child_frames/discovery.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/child_frames/discovery.rs @@ -240,7 +240,7 @@ impl JsContextHost { moli_web_mime::is_html_document_mime(&mime) || moli_web_mime::is_xml_document_mime(&mime) || moli_web_mime::is_text_mime(&mime) - || moli_web_mime::is_json_module_mime(&mime) + || moli_web_mime::is_json_mime(&mime) || moli_web_mime::is_javascript_mime(&mime) } diff --git a/moli-renderer-v8/src/stylesheet_blocking.rs b/moli-renderer-v8/src/stylesheet_blocking.rs index 60dca0533b..808265df2f 100644 --- a/moli-renderer-v8/src/stylesheet_blocking.rs +++ b/moli-renderer-v8/src/stylesheet_blocking.rs @@ -421,6 +421,37 @@ mod tests { assert_eq!(response.body_text(), "body { color: red; }"); } + #[test] + fn validates_stylesheet_response_using_complete_content_type_header_list() { + let url = Url::parse("https://example.com/app.css").unwrap(); + for (values, accepts) in [ + (vec!["text/plain", "text/css"], true), + (vec!["text/plain, text/css"], true), + (vec!["TEXT/CSS; charset=utf-8", "invalid", "*/*"], true), + (vec!["text/css", "text/plain"], false), + (vec!["text/css, text/plain"], false), + (vec![r#"text/plain; a=",text/css""#], false), + ] { + for nosniff in [false, true] { + let mut response = stylesheet_response(&url, None, "body { color: red; }"); + response.headers = values + .iter() + .map(|value| ("Content-Type".to_owned(), value.as_bytes().to_vec())) + .collect(); + if nosniff { + response + .headers + .push(("X-Content-Type-Options".to_owned(), b"nosniff".to_vec())); + } + assert_eq!( + validate_stylesheet_response(&url, response).is_ok(), + accepts, + "nosniff={nosniff}: {values:?}" + ); + } + } + } + #[test] fn linked_stylesheet_request_uses_captured_processing_attributes() { let document_url = Url::parse("https://example.com/page").unwrap(); diff --git a/moli-renderer-v8/src/worker/module_mime.rs b/moli-renderer-v8/src/worker/module_mime.rs index 210e72d1f4..dbf735670a 100644 --- a/moli-renderer-v8/src/worker/module_mime.rs +++ b/moli-renderer-v8/src/worker/module_mime.rs @@ -1,4 +1,4 @@ -use moli_web_mime::{is_text_mime, response_header_values}; +use moli_web_mime::is_text_mime; pub(crate) fn ensure_worker_wasm_module_mime( response: &moli_fetch::Response, @@ -28,7 +28,7 @@ pub(crate) fn ensure_worker_css_module_mime(response: &moli_fetch::Response) -> pub(crate) fn ensure_worker_text_module_mime( response: &moli_fetch::Response, ) -> Result<(), String> { - let content_type = worker_module_response_content_type(&response.headers); + let content_type = super::script_mime::worker_response_content_type(&response.headers); let Some(content_type) = content_type else { return Err( "non-text module response for text import attribute: missing Content-Type".to_owned(), @@ -41,9 +41,3 @@ pub(crate) fn ensure_worker_text_module_mime( "non-text module response for text import attribute: `{content_type}`" )) } - -fn worker_module_response_content_type(headers: &[(String, Vec)]) -> Option { - response_header_values(headers, "content-type") - .into_iter() - .next_back() -} diff --git a/moli-renderer-v8/src/worker/script_mime.rs b/moli-renderer-v8/src/worker/script_mime.rs index 40080d0c64..b42bf28d10 100644 --- a/moli-renderer-v8/src/worker/script_mime.rs +++ b/moli-renderer-v8/src/worker/script_mime.rs @@ -1,5 +1,6 @@ use moli_web_mime::{ - FetchDestination, ScriptResponseMimeError, check_script_response_mime, response_header_values, + FetchDestination, ScriptResponseMimeError, check_script_response_mime, + extract_response_mime_type, }; use url::Url; @@ -13,9 +14,7 @@ pub(crate) fn ensure_worker_script_mime_acceptable( } pub(crate) fn worker_response_content_type(headers: &[(String, Vec)]) -> Option { - response_header_values(headers, "content-type") - .into_iter() - .next_back() + extract_response_mime_type(headers).map(|mime| mime.to_string()) } pub(crate) fn worker_response_has_webassembly_mime(headers: &[(String, Vec)]) -> bool { @@ -87,4 +86,34 @@ mod tests { assert!(ensure_worker_script_mime_acceptable(&url, &headers, b"").is_err()); } + + #[test] + fn worker_javascript_and_wasm_mime_use_complete_header_lists() { + let url = Url::parse("https://example.test/worker").unwrap(); + for mime in ["text/javascript", "application/wasm"] { + for (values, accepts) in [ + (vec!["text/plain".to_owned(), mime.to_owned()], true), + (vec![format!("text/plain, {mime}")], true), + ( + vec![mime.to_owned(), "invalid".to_owned(), "*/*".to_owned()], + true, + ), + (vec![mime.to_owned(), "text/plain".to_owned()], false), + (vec![format!("{mime}, text/plain")], false), + (vec![format!(r#"text/plain; a=",{mime}""#)], false), + (vec![], false), + ] { + let headers: Vec<_> = values + .iter() + .map(|value| ("Content-Type".to_owned(), value.as_bytes().to_vec())) + .collect(); + let accepted = if mime == "application/wasm" { + worker_response_has_webassembly_mime(&headers) + } else { + ensure_worker_script_mime_acceptable(&url, &headers, b"").is_ok() + }; + assert_eq!(accepted, accepts, "{mime}: {values:?}"); + } + } + } } diff --git a/moli-renderer-v8/src/worker/thread/tests/modules.rs b/moli-renderer-v8/src/worker/thread/tests/modules.rs index 13ef8b1601..ed4ea98b45 100644 --- a/moli-renderer-v8/src/worker/thread/tests/modules.rs +++ b/moli-renderer-v8/src/worker/thread/tests/modules.rs @@ -5667,6 +5667,10 @@ async fn worker_importscripts_cross_origin_failure_reports_helper_callsite_from_ async fn worker_json_imports_use_shared_response_mime_extraction() { ensure_v8(); for (mime, accepts) in [ + ("text/json", true), + ("TeXt/JsOn; charset=windows-1250", true), + ("text/plain, text/json", true), + ("text/json, text/plain", false), ("text/plain, application/json", true), ("application/json, invalid, */*", true), ("application/json, text/plain", false), diff --git a/moli-web-mime/src/classification.rs b/moli-web-mime/src/classification.rs index 9574cdde24..a50fca152e 100644 --- a/moli-web-mime/src/classification.rs +++ b/moli-web-mime/src/classification.rs @@ -64,7 +64,7 @@ pub fn is_text_mime_essence(input: &str) -> bool { pub fn is_text_document_mime(input: &str) -> bool { !is_html_document_mime(input) && !is_xml_document_mime(input) - && (is_text_mime(input) || is_json_module_mime(input) || is_javascript_mime(input)) + && (is_text_mime(input) || is_json_mime(input) || is_javascript_mime(input)) } /// Whether a `style` element's raw `type` attribute selects classic CSS. @@ -131,6 +131,16 @@ pub fn is_font_mime(input: &str) -> bool { pub fn is_font_mime_essence(input: &str) -> bool { input.starts_with("font/") + || matches!( + input, + "application/font-cff" + | "application/font-otf" + | "application/font-sfnt" + | "application/font-ttf" + | "application/font-woff" + | "application/vnd.ms-fontobject" + | "application/vnd.ms-opentype" + ) } pub fn is_form_urlencoded_mime(input: &str) -> bool { @@ -147,14 +157,13 @@ pub fn multipart_form_data_boundary(input: &str) -> Option { .flatten() } -pub fn is_json_module_mime(input: &str) -> bool { - mime_essence(input).is_some_and(|mime| mime == "application/json" || mime.ends_with("+json")) +/// The JSON MIME type group shared by JSON modules and documents. +pub fn is_json_mime(input: &str) -> bool { + mime_essence(input).is_some_and(|mime| is_json_mime_essence(&mime)) } -pub fn is_json_document_mime(input: &str) -> bool { - mime_essence(input).is_some_and(|mime| { - matches!(mime.as_str(), "application/json" | "text/json") || mime.ends_with("+json") - }) +pub fn is_json_mime_essence(input: &str) -> bool { + matches!(input, "application/json" | "text/json") || input.ends_with("+json") } pub fn is_webassembly_mime(input: &str) -> bool { @@ -174,8 +183,7 @@ pub fn is_supported_document_mime_type(input: &str) -> bool { || is_audio_mime_essence(&essence) || is_video_mime_essence(&essence) || is_javascript_mime_essence(&essence) - || essence == "application/json" - || essence.ends_with("+json") + || is_json_mime_essence(&essence) || matches!( essence.as_str(), "application/xml" diff --git a/moli-web-mime/src/lib.rs b/moli-web-mime/src/lib.rs index bcae3a9eb8..4669dfb1b8 100644 --- a/moli-web-mime/src/lib.rs +++ b/moli-web-mime/src/lib.rs @@ -13,7 +13,7 @@ pub use classification::{ is_css_style_element_type_attribute, is_css_stylesheet_type_hint, is_dom_parser_xml_mime, is_font_mime, is_font_mime_essence, is_form_urlencoded_mime, is_html_document_mime, is_image_mime, is_image_mime_essence, is_javascript_mime, is_javascript_mime_essence, - is_json_document_mime, is_json_module_mime, is_multipart_form_data_mime, is_png_image_mime, + is_json_mime, is_json_mime_essence, is_multipart_form_data_mime, is_png_image_mime, is_png_image_mime_essence, is_supported_document_mime_type, is_svg_image_mime, is_svg_image_mime_essence, is_text_document_mime, is_text_mime, is_text_mime_essence, is_video_mime, is_video_mime_essence, is_webassembly_mime, is_xml_document_mime, diff --git a/moli-web-mime/src/response_policy.rs b/moli-web-mime/src/response_policy.rs index 6c560dd7b3..6dcdcb9261 100644 --- a/moli-web-mime/src/response_policy.rs +++ b/moli-web-mime/src/response_policy.rs @@ -1,10 +1,10 @@ use crate::classification::{ is_audio_mime_essence, is_css_mime, is_font_mime_essence, is_image_mime_essence, - is_javascript_mime, is_video_mime_essence, + is_javascript_mime, is_json_mime_essence, is_video_mime_essence, }; use crate::destination::FetchDestination; -use crate::headers::response_header_value; -use crate::parse::{mime_charset, mime_essence}; +use crate::headers::{extract_response_mime_essence, extract_response_mime_type}; +use crate::parse::mime_charset; use crate::sniffing::{MimeSniffingContext, computed_mime_type, sniff_image_mime_type}; pub fn determine_nosniff(headers: &[(String, Vec)]) -> bool { @@ -27,7 +27,7 @@ pub fn should_response_be_blocked_due_to_nosniff( return false; } - let content_type = response_header_value(headers, "content-type"); + let content_type = extract_response_mime_essence(headers); if destination.is_script_like() { return content_type .as_deref() @@ -42,23 +42,10 @@ pub fn should_response_be_blocked_due_to_nosniff( } pub fn should_opaque_response_be_blocked_by_orb(headers: &[(String, Vec)]) -> bool { - let content_type = response_header_value(headers, "content-type"); - if determine_nosniff(headers) - && content_type - .as_deref() - .is_none_or(|content_type| content_type.trim().is_empty()) - { - return true; - } - - let Some(content_type) = content_type else { - return false; - }; - let Some(essence) = mime_essence(&content_type) else { + let Some(essence) = extract_response_mime_essence(headers) else { return determine_nosniff(headers); }; - if is_javascript_mime(&content_type) || is_css_mime(&content_type) || essence == "image/svg+xml" - { + if is_javascript_mime(&essence) || is_css_mime(&essence) || essence == "image/svg+xml" { return false; } @@ -67,9 +54,7 @@ pub fn should_opaque_response_be_blocked_by_orb(headers: &[(String, Vec)]) - || essence == "text/xml" || essence == "application/xml" || essence == "application/xhtml+xml" - || essence == "application/json" - || essence == "text/json" - || essence.ends_with("+json") + || is_json_mime_essence(&essence) || essence == "application/dash+xml" || essence == "application/gzip" || essence == "application/x-gzip" @@ -127,14 +112,12 @@ pub fn should_opaque_response_be_blocked_by_orb_with_body( return false; } - let Some(content_type) = response_header_value(headers, "content-type") else { + let Some(mime) = extract_response_mime_type(headers) else { return true; }; - let Some(essence) = mime_essence(&content_type) else { - return true; - }; - if is_json_like_mime_essence(&essence) - && response_body_looks_like_orb_allowed_javascript(&content_type, body) + let essence = mime.essence(); + if is_json_mime_essence(&essence) + && response_body_looks_like_orb_allowed_javascript(&mime.to_string(), body) { return false; } @@ -147,7 +130,7 @@ pub fn computed_response_mime_type( context: MimeSniffingContext, body: &[u8], ) -> String { - let content_type = response_header_value(headers, "content-type"); + let content_type = extract_response_mime_type(headers).map(|mime| mime.to_string()); computed_mime_type( content_type.as_deref(), determine_nosniff(headers), @@ -176,10 +159,7 @@ pub fn check_script_response_mime( // A script-context sniffing default cannot satisfy an explicit // JavaScript MIME requirement, including when Content-Type is absent // or cannot be parsed. - let supplied_mime_type = response_header_value(headers, "content-type") - .as_deref() - .and_then(mime_essence) - .unwrap_or_default(); + let supplied_mime_type = extract_response_mime_essence(headers).unwrap_or_default(); return is_javascript_mime(&supplied_mime_type) .then_some(()) .ok_or(ScriptResponseMimeError::Unsupported(supplied_mime_type)); @@ -196,10 +176,7 @@ pub fn check_script_response_mime( pub fn should_script_like_response_be_blocked_due_to_mime_type( headers: &[(String, Vec)], ) -> bool { - let Some(content_type) = response_header_value(headers, "content-type") else { - return false; - }; - let Some(essence) = mime_essence(&content_type) else { + let Some(essence) = extract_response_mime_essence(headers) else { return false; }; is_audio_mime_essence(&essence) @@ -208,10 +185,6 @@ pub fn should_script_like_response_be_blocked_due_to_mime_type( || essence == "text/csv" } -fn is_json_like_mime_essence(essence: &str) -> bool { - essence == "application/json" || essence == "text/json" || essence.ends_with("+json") -} - fn response_body_looks_like_orb_allowed_javascript(content_type: &str, body: &[u8]) -> bool { let text = decode_orb_script_candidate(content_type, body); let trimmed = text.trim_start_matches(|ch: char| ch.is_whitespace()); diff --git a/moli-web-mime/src/tests.rs b/moli-web-mime/src/tests.rs index d361b05e03..711db5aaa3 100644 --- a/moli-web-mime/src/tests.rs +++ b/moli-web-mime/src/tests.rs @@ -290,7 +290,7 @@ fn matches_script_and_form_content_types() { assert!(is_video_mime_essence("video/webm")); assert!(is_font_mime("font/woff2")); assert!(is_font_mime_essence("font/ttf")); - assert!(!is_font_mime("application/font-woff")); + assert!(is_font_mime("application/font-woff")); assert_eq!( media_mime_support("Video/MP4; codecs=\"avc1.42E01E\""), MediaMimeSupport::Probably @@ -314,9 +314,9 @@ fn matches_script_and_form_content_types() { ); assert_eq!(multipart_form_data_boundary("text/plain"), None); - assert!(is_json_module_mime("Application/JSON; charset=utf-8")); - assert!(is_json_module_mime("application/manifest+json")); - assert!(!is_json_module_mime("text/json")); + assert!(is_json_mime("Application/JSON; charset=utf-8")); + assert!(is_json_mime("application/manifest+json")); + assert!(is_json_mime("text/json")); assert!(is_media_source_type_supported( "video/mp4; codecs=\"avc1.42E01E\"" @@ -709,6 +709,45 @@ fn orb_body_sniffing_decodes_utf16_javascript_candidates() { )); } +#[test] +fn orb_mime_checks_and_body_decoding_use_the_extracted_response_type() { + for (values, blocked) in [ + (vec!["text/javascript", "text/json"], true), + (vec!["text/javascript, text/json"], true), + (vec!["text/json", "text/javascript"], false), + (vec!["text/json", "invalid", "*/*"], true), + ] { + let headers: Vec<_> = values + .iter() + .map(|value| ("Content-Type".to_owned(), value.as_bytes().to_vec())) + .collect(); + assert_eq!( + should_opaque_response_be_blocked_by_orb(&headers), + blocked, + "{values:?}" + ); + assert_eq!( + should_opaque_response_be_blocked_by_orb_with_body(&headers, br#"{"ok":true}"#), + blocked, + "{values:?}" + ); + } + let headers = [ + ("Content-Type".to_owned(), b"text/plain".to_vec()), + ( + "Content-Type".to_owned(), + b"text/json; charset=utf-16, text/json".to_vec(), + ), + ]; + let body: Vec = "\"use strict\";" + .encode_utf16() + .flat_map(u16::to_le_bytes) + .collect(); + assert!(!should_opaque_response_be_blocked_by_orb_with_body( + &headers, &body + )); +} + #[test] fn computes_response_mime_type_from_headers_and_body() { let image_without_type = @@ -927,12 +966,157 @@ fn navigation_xml_mime_classification_keeps_dom_parser_allowlist_separate() { } #[test] -fn navigation_json_mime_includes_text_json_without_changing_module_mime() { - for mime in ["application/json", "application/problem+json"] { - assert!(is_json_document_mime(mime), "{mime}"); - assert!(is_json_module_mime(mime), "{mime}"); +fn json_mime_group_includes_text_json_and_valid_suffixes() { + for mime in [ + "application/json", + "application/problem+json", + "text/html+json", + "image/svg+json", + "text/json", + "Text/JSON; charset=utf-8", + "text/json; charset=windows-1250", + "text/json; boundary=something", + "text/json; foo=bar", + "text/json; +json", + ] { + assert!(is_json_mime(mime), "{mime}"); + } + for mime in [ + "text/plain", + "application/javascript", + "application/jsonp", + "text/jsonp", + "application/json+xml", + "text/plain; json=application/json", + "invalid+json", + "text /json", + "text/ json", + ] { + assert!(!is_json_mime(mime), "{mime}"); } - assert!(is_json_document_mime("Text/JSON; charset=utf-8")); - assert!(!is_json_module_mime("Text/JSON; charset=utf-8")); - assert!(!is_json_document_mime("text/plain")); +} + +#[test] +fn font_mime_group_includes_registered_application_aliases() { + for mime in [ + "font/woff2", + "application/font-cff", + "application/font-otf", + "application/font-sfnt", + "application/font-ttf", + "application/font-woff", + "application/vnd.ms-fontobject", + "application/vnd.ms-opentype", + ] { + assert!(is_font_mime_essence(mime), "{mime}"); + assert!(is_font_mime(mime), "{mime}"); + assert!(is_font_mime(&format!( + "{}; version=1", + mime.to_ascii_uppercase() + ))); + assert!(is_binary_document_mime_type(mime), "{mime}"); + assert!( + should_opaque_response_be_blocked_by_orb(&[( + "Content-Type".to_owned(), + mime.as_bytes().to_vec(), + )]), + "{mime}" + ); + } + for mime in [ + "application/font-woff2", + "application/x-font-ttf", + "fontish/woff", + "font/", + ] { + assert!(!is_font_mime(mime), "{mime}"); + } +} + +#[test] +fn script_and_style_policies_extract_mime_from_the_complete_header_list() { + for (mime, destination) in [ + ("text/javascript", FetchDestination::Script), + ("text/javascript", FetchDestination::Worker), + ("text/css", FetchDestination::Style), + ] { + for (values, accepts) in [ + (vec!["text/plain".to_owned(), mime.to_owned()], true), + (vec![format!("text/plain, {mime}")], true), + ( + vec![mime.to_owned(), "invalid".to_owned(), "*/*".to_owned()], + true, + ), + (vec![mime.to_owned(), "text/plain".to_owned()], false), + (vec![format!("{mime}, text/plain")], false), + (vec![format!(r#"text/plain; a=",{mime}""#)], false), + ( + vec![r#"text/plain; a=""#.to_owned(), mime.to_owned()], + false, + ), + (vec!["invalid".to_owned(), "*/*".to_owned()], false), + (vec![], false), + ] { + let mut headers: Vec<_> = values + .iter() + .map(|value| ("Content-Type".to_owned(), value.as_bytes().to_vec())) + .collect(); + if destination.is_script_like() { + assert_eq!( + check_script_response_mime(&headers, b"", destination, true).is_ok(), + accepts, + "{destination:?}: {values:?}" + ); + } + headers.push(("X-Content-Type-Options".to_owned(), b"nosniff".to_vec())); + assert_eq!( + should_response_be_blocked_due_to_nosniff(&headers, destination), + !accepts, + "{destination:?}: {values:?}" + ); + if destination.is_script_like() { + assert_eq!( + check_script_response_mime(&headers, b"", destination, true).is_ok(), + accepts, + "{destination:?}: {values:?}" + ); + } + } + } +} + +#[test] +fn classic_script_mime_block_uses_extracted_type_and_keeps_fetch_prefix_rules() { + for blocked in ["audio/mpeg", "image/png", "video/mp4", "text/csv"] { + for values in [ + vec!["text/javascript".to_owned(), blocked.to_owned()], + vec![format!("text/javascript, {blocked}")], + ] { + let headers: Vec<_> = values + .iter() + .map(|value| ("Content-Type".to_owned(), value.as_bytes().to_vec())) + .collect(); + assert!( + should_script_like_response_be_blocked_due_to_mime_type(&headers), + "{values:?}" + ); + assert_eq!( + check_script_response_mime(&headers, b"", FetchDestination::Script, false), + Err(ScriptResponseMimeError::Unsupported(blocked.to_owned())), + "{values:?}" + ); + } + let headers = [( + "Content-Type".to_owned(), + format!("{blocked}, text/javascript").into_bytes(), + )]; + assert!(!should_script_like_response_be_blocked_due_to_mime_type( + &headers + )); + assert!(check_script_response_mime(&headers, b"", FetchDestination::Script, false).is_ok()); + } + let ogg = [("Content-Type".to_owned(), b"application/ogg".to_vec())]; + assert!(!should_script_like_response_be_blocked_due_to_mime_type( + &ogg + )); }