From ded949c193e3d529b2c4ba8fbcd431efdf027784 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Mon, 14 Sep 2026 03:36:05 +0800 Subject: [PATCH] fix(stylesheets): honor quirks MIME compatibility Source: 80c50cfbf5727b8a1758b5e45dd7514af34f4a6f --- moli-dom/src/native/document.rs | 4 + moli-parser/src/live_target.rs | 4 + moli-parser/src/stylesheet_blocking.rs | 34 ++++ moli-renderer-v8/src/document_runtime.rs | 24 +++ .../child_documents/live_parser.rs | 8 + moli-renderer-v8/src/runtime/phase_one/mod.rs | 26 +++ .../src/runtime/script_preloads.rs | 12 +- moli-renderer-v8/src/stylesheet_blocking.rs | 161 +++++++++++++++++- moli-stylesheet-blocking/src/discovery.rs | 15 +- moli-stylesheet-blocking/src/fetcher.rs | 15 +- moli-stylesheet-blocking/src/state.rs | 29 ++++ 11 files changed, 321 insertions(+), 11 deletions(-) diff --git a/moli-dom/src/native/document.rs b/moli-dom/src/native/document.rs index 1b24603fcb..b09ff49e4a 100644 --- a/moli-dom/src/native/document.rs +++ b/moli-dom/src/native/document.rs @@ -118,6 +118,10 @@ impl Document { self.quirks_mode } + pub fn is_quirks_mode(&self) -> bool { + self.quirks_mode == QuirksMode::Quirks + } + pub fn kind(&self) -> DocumentKind { self.kind } diff --git a/moli-parser/src/live_target.rs b/moli-parser/src/live_target.rs index 52792b34f0..bb5eccbd5c 100644 --- a/moli-parser/src/live_target.rs +++ b/moli-parser/src/live_target.rs @@ -3598,6 +3598,10 @@ impl StylesheetBlockingReadView for ParserStreamHtmlTreeSinkTarget { self.parser_owner_document_node_id() } + fn document_is_quirks_mode(&self) -> bool { + matches!(self.state.html_quirks_mode, QuirksMode::Quirks) + } + fn document_order_stylesheet_candidate_ids_before( &self, target_node_id: Option, diff --git a/moli-parser/src/stylesheet_blocking.rs b/moli-parser/src/stylesheet_blocking.rs index 44b704975c..5155303fc1 100644 --- a/moli-parser/src/stylesheet_blocking.rs +++ b/moli-parser/src/stylesheet_blocking.rs @@ -43,6 +43,12 @@ impl StylesheetBlockingReadView for ParserStreamDocumentSnapshot { self.document_node_id() } + fn document_is_quirks_mode(&self) -> bool { + self.node(self.document_node_id()) + .and_then(moli_dom::native::Node::as_document) + .is_some_and(|document| document.is_quirks_mode()) + } + fn document_order_stylesheet_candidate_ids_before( &self, target_node_id: Option, @@ -158,6 +164,34 @@ mod tests { disposition.url().as_str(), "https://example.com/assets/app.css" ); + assert!(!disposition.options().quirks_mode_mime_compatibility()); + } + + #[test] + fn stylesheet_link_captures_quirks_mode_mime_compatibility() { + let parser = HtmlParser::SCRIPTING_ENABLED; + let document = parser.parse( + url::Url::parse("https://example.com/page.html").unwrap(), + "".to_owned(), + ); + let link = document + .document_head_handle() + .and_then(|head| document.child_nodes(head)) + .and_then(|children| { + children.into_iter().find(|handle| { + document + .node(*handle) + .and_then(Node::as_element) + .is_some_and(|element| element.is_html_element("link")) + }) + }) + .expect("stylesheet link"); + + let disposition = + stylesheet_link_disposition(&document, moli_dom::NodeId::new(link.index())) + .expect("stylesheet disposition"); + + assert!(disposition.options().quirks_mode_mime_compatibility()); } #[test] diff --git a/moli-renderer-v8/src/document_runtime.rs b/moli-renderer-v8/src/document_runtime.rs index eb86c9d25a..b89a890627 100644 --- a/moli-renderer-v8/src/document_runtime.rs +++ b/moli-renderer-v8/src/document_runtime.rs @@ -1287,6 +1287,12 @@ impl super::stylesheet_blocking::StylesheetBlockingReadView for LiveRuntimeDomHo self.borrow().document_node_id() } + fn document_is_quirks_mode(&self) -> bool { + ::document_is_quirks_mode( + self.borrow(), + ) + } + fn document_order_stylesheet_candidate_ids_before( &self, target_node_id: Option, @@ -1397,6 +1403,24 @@ mod tests { ); } + #[test] + fn live_runtime_dom_host_forwards_quirks_mode_for_stylesheet_processing() { + let document = HtmlParser::SCRIPTING_ENABLED.parse( + Url::parse("https://example.test/page.html").unwrap(), + "".to_owned(), + ); + let link = first_element_handle(&document, "link"); + let host = LiveRuntimeDomHost::from_dom_host(DomHost::from_dom(document)); + + let disposition = crate::stylesheet_blocking::stylesheet_link_disposition( + &host, + NodeId::new(link.index()), + ) + .expect("stylesheet disposition"); + + assert!(disposition.options().quirks_mode_mime_compatibility()); + } + #[test] fn dom_host_builds_from_parsed_document_and_updates_text_content() { let parser = HtmlParser::SCRIPTING_ENABLED; diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_documents/live_parser.rs b/moli-renderer-v8/src/native_bridge/context_host/child_documents/live_parser.rs index 6fc592f8cc..e4760b2cde 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/child_documents/live_parser.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/child_documents/live_parser.rs @@ -119,6 +119,14 @@ impl StylesheetBlockingReadView for ChildFrameLiveParserOwner<'_, '_, '_> { self.child_document_handle } + fn document_is_quirks_mode(&self) -> bool { + self.host + .dom_host() + .node(self.child_document_handle) + .and_then(Node::as_document) + .is_some_and(|document| document.is_quirks_mode()) + } + fn document_order_stylesheet_candidate_ids_before( &self, target_node_id: Option, diff --git a/moli-renderer-v8/src/runtime/phase_one/mod.rs b/moli-renderer-v8/src/runtime/phase_one/mod.rs index 3c37993bbb..7121b82a21 100644 --- a/moli-renderer-v8/src/runtime/phase_one/mod.rs +++ b/moli-renderer-v8/src/runtime/phase_one/mod.rs @@ -3460,6 +3460,32 @@ document.body.setAttribute('data-error-state', [ ); } + #[test] + fn quirks_stylesheet_parser_client_claims_mode_neutral_scanner_descriptor() { + let final_url = Url::parse("https://example.test/docs/page.html").expect("test url"); + let loader = ResourceRequestClient::new(&FetchConfig::default()).expect("default loader"); + let mut cache = BufferedDocumentPreloadState::default(); + cache.append_to_main_document_scan( + &final_url, + r#""#, + &loader, + ); + assert_eq!(cache.pending_preload_counts_for_test(), (0, 1)); + + let stylesheet_candidate = + moli_stylesheet_blocking::DocumentOwnedBlockingStylesheetCandidate::Link { + node_id: NodeId::new(11), + url: Url::parse("https://example.test/app.css").expect("stylesheet URL"), + options: crate::stylesheet_blocking::StylesheetFetchOptions::default() + .with_quirks_mode_mime_compatibility(true), + }; + cache.claim_pending_stylesheet_preloads_for_parser(&[ + DocumentOwnedBlockingStylesheetDiscoveryInput::from(&stylesheet_candidate), + ]); + + assert_eq!(cache.pending_preload_counts_for_test(), (0, 0)); + } + #[test] fn meta_csp_pending_descriptor_budget_falls_back_to_parser() { let final_url = Url::parse("https://example.test/docs/page.html").expect("test url"); diff --git a/moli-renderer-v8/src/runtime/script_preloads.rs b/moli-renderer-v8/src/runtime/script_preloads.rs index d3bffeacd3..8927a37535 100644 --- a/moli-renderer-v8/src/runtime/script_preloads.rs +++ b/moli-renderer-v8/src/runtime/script_preloads.rs @@ -504,7 +504,17 @@ impl BufferedDocumentPreloadState { .iter() .filter_map(|input| match input.signature() { DocumentBlockingStylesheetSignature::Link { url, options } => { - Some(options.resource_key(url.clone())) + // The scanner runs ahead of the parser and cannot know the + // Document's final quirks mode. MIME compatibility changes + // response processing, not the request metadata, so claim + // the buffered descriptor using the scanner's neutral + // processing mode before it starts a duplicate fetch. + Some( + options + .clone() + .with_quirks_mode_mime_compatibility(false) + .resource_key(url.clone()), + ) } DocumentBlockingStylesheetSignature::ParserCreatedStyleImport { .. } => None, }) diff --git a/moli-renderer-v8/src/stylesheet_blocking.rs b/moli-renderer-v8/src/stylesheet_blocking.rs index 51b63e63e2..5744d75796 100644 --- a/moli-renderer-v8/src/stylesheet_blocking.rs +++ b/moli-renderer-v8/src/stylesheet_blocking.rs @@ -264,11 +264,16 @@ enum StylesheetResponseProvenance { } impl StylesheetResponseProvenance { - fn is_cors_same_origin(self, document_url: &Url, head: &moli_fetch::ResponseHead) -> bool { + fn is_cors_same_origin( + self, + document_url: &Url, + request_url: &Url, + response: &crate::protocol_types::NavigationResponse, + ) -> bool { match self { - Self::Network => !head - .url_list() - .has_cross_origin_url(&moli_url::WebOrigin::from_url(document_url)), + Self::Network => { + stylesheet_response_url_chain_is_same_origin(document_url, request_url, response) + } Self::ServiceWorker { filter } => !matches!( filter, Some( @@ -280,6 +285,18 @@ impl StylesheetResponseProvenance { } } +fn stylesheet_response_url_chain_is_same_origin( + document_url: &Url, + request_url: &Url, + response: &crate::protocol_types::NavigationResponse, +) -> bool { + moli_url::same_origin(document_url, request_url) + && !response + .head() + .url_list() + .has_cross_origin_url(&moli_url::WebOrigin::from_url(document_url)) +} + fn stylesheet_terminal_from_response( document_url: &Url, request_url: &Url, @@ -311,7 +328,7 @@ fn stylesheet_terminal_from_response( } }); let origin_clean = cors_usability.as_ref().map_or_else( - || response_provenance.is_cors_same_origin(document_url, &head), + || response_provenance.is_cors_same_origin(document_url, request_url, &response), Result::is_ok, ); let usability = if !(200..=299).contains(&response.status) { @@ -322,7 +339,11 @@ fn stylesheet_terminal_from_response( } else { cors_usability.unwrap_or(Ok(())) } - .and_then(|()| validate_stylesheet_response_ref(request_url, &response)); + .and_then(|()| { + let allow_non_css_mime = options.quirks_mode_mime_compatibility() + && stylesheet_response_url_chain_is_same_origin(document_url, request_url, &response); + validate_stylesheet_response_ref(request_url, &response, allow_non_css_mime) + }); match usability { Ok(()) => StylesheetFetchTerminal::ready(response, origin_clean), @@ -334,13 +355,14 @@ pub(crate) fn validate_stylesheet_response( url: &Url, response: crate::protocol_types::NavigationResponse, ) -> Result { - validate_stylesheet_response_ref(url, &response)?; + validate_stylesheet_response_ref(url, &response, false)?; Ok(response) } fn validate_stylesheet_response_ref( url: &Url, response: &crate::protocol_types::NavigationResponse, + allow_non_css_mime: bool, ) -> Result<(), String> { if should_response_be_blocked_due_to_nosniff(&response.headers, FetchDestination::Style) { return Err(format!( @@ -352,7 +374,7 @@ fn validate_stylesheet_response_ref( MimeSniffingContext::Style, response.body_bytes(), ); - if !is_css_mime(&computed_mime_type) { + if !allow_non_css_mime && !is_css_mime(&computed_mime_type) { return Err(format!( "failed to fetch stylesheet `{url}`: unsupported stylesheet MIME type `{computed_mime_type}`" )); @@ -380,6 +402,24 @@ mod tests { ) } + fn stylesheet_redirect(from_url: &Url, to_url: &Url) -> crate::types::NavigationRedirect { + crate::types::NavigationRedirect { + source: moli_fetch::RedirectSource::Network, + from_url: from_url.clone(), + to_url: to_url.clone(), + status: 302, + headers: Vec::new(), + network_extra_info_available: true, + request_extra_info: None, + response_extra_info: None, + redirect_has_extra_info: true, + request_cookie_report: None, + cookie_set_reports: Vec::new(), + from_cache: false, + negotiated_http_version: None, + } + } + #[test] fn validates_stylesheet_response_rejects_explicit_non_css_mime() { let url = Url::parse("https://example.com/app.css").unwrap(); @@ -402,6 +442,111 @@ mod tests { assert_eq!(response.body_text(), "body { color: red; }"); } + #[test] + fn quirks_mode_mime_compatibility_requires_same_origin_final_url() { + let document_url = Url::parse("https://page.example.test/document").unwrap(); + let request_url = Url::parse("https://page.example.test/app.css").unwrap(); + let options = StylesheetFetchOptions::default().with_quirks_mode_mime_compatibility(true); + let same_origin_response = + stylesheet_response(&request_url, Some("text/plain"), "body { color: green; }"); + + let same_origin_terminal = stylesheet_terminal_from_response( + &document_url, + &request_url, + &options, + same_origin_response, + StylesheetResponseProvenance::Network, + ); + + assert!(same_origin_terminal.is_ready()); + + let cross_origin_url = Url::parse("https://cdn.example.test/app.css").unwrap(); + let cross_origin_response = stylesheet_response( + &cross_origin_url, + Some("text/plain"), + "body { color: red; }", + ); + let cross_origin_terminal = stylesheet_terminal_from_response( + &document_url, + &request_url, + &options, + cross_origin_response, + StylesheetResponseProvenance::Network, + ); + + assert!(!cross_origin_terminal.is_ready()); + } + + #[test] + fn quirks_mode_mime_compatibility_rejects_cross_origin_redirect_taint() { + let document_url = Url::parse("https://page.example.test/document").unwrap(); + let same_origin_url = Url::parse("https://page.example.test/app.css").unwrap(); + let cross_origin_url = Url::parse("https://cdn.example.test/app.css").unwrap(); + let options = StylesheetFetchOptions::default().with_quirks_mode_mime_compatibility(true); + + let mut cross_to_same_response = + stylesheet_response(&same_origin_url, Some("text/plain"), "body { color: red; }"); + cross_to_same_response.redirected = true; + cross_to_same_response.redirect_chain = + vec![stylesheet_redirect(&cross_origin_url, &same_origin_url)]; + + let cross_to_same_terminal = stylesheet_terminal_from_response( + &document_url, + &cross_origin_url, + &options, + cross_to_same_response, + StylesheetResponseProvenance::Network, + ); + + assert!(!cross_to_same_terminal.is_ready()); + assert_eq!(cross_to_same_terminal.origin_clean(), Some(false)); + + let mut through_cross_response = + stylesheet_response(&same_origin_url, Some("text/plain"), "body { color: red; }"); + through_cross_response.redirected = true; + through_cross_response.redirect_chain = vec![ + stylesheet_redirect(&same_origin_url, &cross_origin_url), + stylesheet_redirect(&cross_origin_url, &same_origin_url), + ]; + + let through_cross_terminal = stylesheet_terminal_from_response( + &document_url, + &same_origin_url, + &options, + through_cross_response, + StylesheetResponseProvenance::Network, + ); + + assert!(!through_cross_terminal.is_ready()); + assert_eq!(through_cross_terminal.origin_clean(), Some(false)); + } + + #[test] + fn quirks_mode_mime_compatibility_does_not_bypass_nosniff() { + let document_url = Url::parse("https://page.example.test/document").unwrap(); + let stylesheet_url = Url::parse("https://page.example.test/app.css").unwrap(); + let options = StylesheetFetchOptions::default().with_quirks_mode_mime_compatibility(true); + let response = crate::protocol_types::NavigationResponse::from_text_body( + stylesheet_url.clone(), + 200, + vec![ + ("Content-Type".to_owned(), "text/plain".to_owned()), + ("x-content-type-options".to_owned(), "nosniff".to_owned()), + ], + "body { color: red; }".to_owned(), + ); + + let terminal = stylesheet_terminal_from_response( + &document_url, + &stylesheet_url, + &options, + response, + StylesheetResponseProvenance::Network, + ); + + assert!(!terminal.is_ready()); + } + #[test] fn linked_stylesheet_request_uses_captured_processing_attributes() { let document_url = Url::parse("https://example.com/page").unwrap(); diff --git a/moli-stylesheet-blocking/src/discovery.rs b/moli-stylesheet-blocking/src/discovery.rs index 14103b485a..7e60971138 100644 --- a/moli-stylesheet-blocking/src/discovery.rs +++ b/moli-stylesheet-blocking/src/discovery.rs @@ -251,6 +251,7 @@ pub trait StylesheetBlockingReadView { fn final_url_clone(&self) -> Option; fn document_base_url_clone(&self) -> Option; fn document_node_id(&self) -> NativeNodeId; + fn document_is_quirks_mode(&self) -> bool; fn document_order_stylesheet_candidate_ids_before( &self, @@ -284,6 +285,11 @@ impl StylesheetBlockingReadView for NativeDom { self.document_node_id() } + fn document_is_quirks_mode(&self) -> bool { + self.document() + .is_some_and(|document| document.is_quirks_mode()) + } + fn document_order_stylesheet_candidate_ids_before( &self, target_node_id: Option, @@ -325,6 +331,12 @@ impl StylesheetBlockingReadView for DomHost { self.document_handle() } + fn document_is_quirks_mode(&self) -> bool { + self.node(self.document_handle()) + .and_then(Node::as_document) + .is_some_and(|document| document.is_quirks_mode()) + } + fn document_order_stylesheet_candidate_ids_before( &self, target_node_id: Option, @@ -409,7 +421,8 @@ fn stylesheet_link_disposition_in_view( element.nonce.as_deref(), element.charset.as_deref(), element.fetch_priority.as_deref(), - ); + ) + .with_quirks_mode_mime_compatibility(document.document_is_quirks_mode()); let is_alternate = link_rel_includes_token(rel, "alternate"); let blocking = !is_alternate && media_blocks_scripts(element.media.as_deref()); Some(if blocking { diff --git a/moli-stylesheet-blocking/src/fetcher.rs b/moli-stylesheet-blocking/src/fetcher.rs index 1d39a8121b..a0df975477 100644 --- a/moli-stylesheet-blocking/src/fetcher.rs +++ b/moli-stylesheet-blocking/src/fetcher.rs @@ -19,7 +19,7 @@ use crate::types::{ #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct StylesheetFetchOptions(Arc); -#[derive(Debug, Default, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] struct StylesheetFetchOptionsData { cross_origin: Option, referrer_policy: Option, @@ -27,6 +27,7 @@ struct StylesheetFetchOptionsData { nonce: Option, charset: Option, fetch_priority: Option, + quirks_mode_mime_compatibility: bool, } impl Default for StylesheetFetchOptions { @@ -51,9 +52,15 @@ impl StylesheetFetchOptions { nonce: normalize_preserved_value(nonce), charset: normalize_token(charset), fetch_priority: normalize_token(fetch_priority), + quirks_mode_mime_compatibility: false, })) } + pub fn with_quirks_mode_mime_compatibility(mut self, enabled: bool) -> Self { + Arc::make_mut(&mut self.0).quirks_mode_mime_compatibility = enabled; + self + } + pub fn cross_origin(&self) -> Option<&str> { self.0.cross_origin.as_deref() } @@ -78,6 +85,10 @@ impl StylesheetFetchOptions { self.0.fetch_priority.as_deref() } + pub fn quirks_mode_mime_compatibility(&self) -> bool { + self.0.quirks_mode_mime_compatibility + } + pub fn is_empty(&self) -> bool { self == &Self::default() } @@ -122,6 +133,7 @@ pub struct StylesheetResourceKey { referrer_policy: Option, integrity: Option, charset: Option, + quirks_mode_mime_compatibility: bool, } impl StylesheetResourceKey { @@ -135,6 +147,7 @@ impl StylesheetResourceKey { referrer_policy: options.referrer_policy().map(str::to_owned), integrity: options.integrity().map(str::to_owned), charset: options.charset().map(str::to_owned), + quirks_mode_mime_compatibility: options.quirks_mode_mime_compatibility(), } } diff --git a/moli-stylesheet-blocking/src/state.rs b/moli-stylesheet-blocking/src/state.rs index 95ed635a40..1d121f7446 100644 --- a/moli-stylesheet-blocking/src/state.rs +++ b/moli-stylesheet-blocking/src/state.rs @@ -767,6 +767,10 @@ mod tests { NativeNodeId::new(0) } + fn document_is_quirks_mode(&self) -> bool { + false + } + fn document_order_stylesheet_candidate_ids_before( &self, _target_node_id: Option, @@ -1500,6 +1504,31 @@ mod tests { assert!(!plain_fetch.ptr_eq(&anonymous_fetch)); } + #[tokio::test] + async fn physical_key_keeps_mime_processing_compatibility_boundary() { + let mut state = StylesheetBlockingState::default(); + let document_url = Url::parse("https://example.com/").expect("static document url"); + let stylesheet_url = + Url::parse("https://example.com/shared.css").expect("static stylesheet url"); + let standard = StylesheetFetchOptions::default(); + let quirks = StylesheetFetchOptions::default().with_quirks_mode_mime_compatibility(true); + + let standard_fetch = state.preload_stylesheet( + &PendingStylesheetFetcher, + document_url.clone(), + stylesheet_url.clone(), + standard, + ); + let quirks_fetch = state.preload_stylesheet( + &PendingStylesheetFetcher, + document_url, + stylesheet_url, + quirks, + ); + + assert!(!standard_fetch.ptr_eq(&quirks_fetch)); + } + #[tokio::test] async fn physical_key_ignores_fragment_nonce_and_fetch_priority() { let mut state = StylesheetBlockingState::default();