diff --git a/moli-dom/src/native/document.rs b/moli-dom/src/native/document.rs index fc62d5212b..13f4374f5d 100644 --- a/moli-dom/src/native/document.rs +++ b/moli-dom/src/native/document.rs @@ -128,6 +128,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 de9122fa8b..b28e61bd99 100644 --- a/moli-parser/src/live_target.rs +++ b/moli-parser/src/live_target.rs @@ -3782,6 +3782,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 19722d5e90..532f11ae4b 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 f4fc42ccf5..be9b38930c 100644 --- a/moli-renderer-v8/src/document_runtime.rs +++ b/moli-renderer-v8/src/document_runtime.rs @@ -1253,6 +1253,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, @@ -1363,6 +1369,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 dad914b2c8..a0793c65f7 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 @@ -172,6 +172,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 fd65d4b3d0..8f592b25c5 100644 --- a/moli-renderer-v8/src/runtime/phase_one/mod.rs +++ b/moli-renderer-v8/src/runtime/phase_one/mod.rs @@ -3519,6 +3519,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 de390a6778..c8ccec45ec 100644 --- a/moli-renderer-v8/src/runtime/script_preloads.rs +++ b/moli-renderer-v8/src/runtime/script_preloads.rs @@ -531,7 +531,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 60dca0533b..5175dfedf0 100644 --- a/moli-renderer-v8/src/stylesheet_blocking.rs +++ b/moli-renderer-v8/src/stylesheet_blocking.rs @@ -284,10 +284,13 @@ impl StylesheetResponseProvenance { fn is_cors_same_origin( self, request_origin: &moli_url::WebOrigin, + request_url: &Url, head: &moli_fetch::ResponseHead, ) -> bool { match self { - Self::Network => !head.url_list().has_cross_origin_url(request_origin), + Self::Network => { + stylesheet_response_url_chain_is_same_origin(request_origin, request_url, head) + } Self::ServiceWorker { filter } => !matches!( filter, Some( @@ -299,6 +302,15 @@ impl StylesheetResponseProvenance { } } +fn stylesheet_response_url_chain_is_same_origin( + request_origin: &moli_url::WebOrigin, + request_url: &Url, + head: &moli_fetch::ResponseHead, +) -> bool { + request_origin.same_origin(&moli_url::WebOrigin::from_url(request_url)) + && !head.url_list().has_cross_origin_url(request_origin) +} + fn stylesheet_terminal_from_response( request_origin: &moli_url::WebOrigin, request_url: &Url, @@ -330,7 +342,7 @@ fn stylesheet_terminal_from_response( } }); let origin_clean = cors_usability.as_ref().map_or_else( - || response_provenance.is_cors_same_origin(request_origin, &head), + || response_provenance.is_cors_same_origin(request_origin, request_url, &head), Result::is_ok, ); let usability = if !(200..=299).contains(&response.status) { @@ -341,7 +353,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(request_origin, request_url, &head); + validate_stylesheet_response_ref(request_url, &response, allow_non_css_mime) + }); match usability { Ok(()) => StylesheetFetchTerminal::ready(response, origin_clean), @@ -353,13 +369,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!( @@ -371,7 +388,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}`" )); @@ -399,6 +416,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(); @@ -421,6 +456,136 @@ 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( + &moli_url::WebOrigin::from_url(&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( + &moli_url::WebOrigin::from_url(&document_url), + &request_url, + &options, + cross_origin_response, + StylesheetResponseProvenance::Network, + ); + + assert!(!cross_origin_terminal.is_ready()); + } + + #[test] + fn quirks_mode_mime_compatibility_uses_committed_request_origin() { + let request_url = Url::parse("https://page.example.test/app.css").unwrap(); + let options = StylesheetFetchOptions::default().with_quirks_mode_mime_compatibility(true); + let inherited_origin = moli_url::WebOrigin::from_serialized("https://page.example.test"); + + for (request_origin, expected_ready) in [ + (inherited_origin, true), + (moli_url::WebOrigin::Opaque, false), + ] { + let response = + stylesheet_response(&request_url, Some("text/plain"), "body { color: green; }"); + let terminal = stylesheet_terminal_from_response( + &request_origin, + &request_url, + &options, + response, + StylesheetResponseProvenance::Network, + ); + + assert_eq!(terminal.is_ready(), expected_ready); + assert_eq!(terminal.origin_clean(), Some(expected_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( + &moli_url::WebOrigin::from_url(&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( + &moli_url::WebOrigin::from_url(&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( + &moli_url::WebOrigin::from_url(&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 d7b4373c40..d58be82217 100644 --- a/moli-stylesheet-blocking/src/discovery.rs +++ b/moli-stylesheet-blocking/src/discovery.rs @@ -254,6 +254,7 @@ pub trait StylesheetBlockingReadView { self.document_base_url_clone() } fn document_node_id(&self) -> NativeNodeId; + fn document_is_quirks_mode(&self) -> bool; fn document_order_stylesheet_candidate_ids_before( &self, @@ -287,6 +288,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, @@ -333,6 +339,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, @@ -423,7 +435,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 90ed2efa00..3395ea604f 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 35d228749f..c4e1228cb2 100644 --- a/moli-stylesheet-blocking/src/state.rs +++ b/moli-stylesheet-blocking/src/state.rs @@ -775,6 +775,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, @@ -1508,6 +1512,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();