fix(stylesheets): honor quirks MIME compatibility

Source: 80c50cfbf5
This commit is contained in:
ldm0
2026-09-14 03:36:05 +08:00
parent ca14f3193f
commit ded949c193
11 changed files with 321 additions and 11 deletions
+4
View File
@@ -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
}
+4
View File
@@ -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<NodeId>,
+34
View File
@@ -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<NodeId>,
@@ -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(),
"<html><head><link rel=stylesheet href=app.css></head></html>".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]
+24
View File
@@ -1287,6 +1287,12 @@ impl super::stylesheet_blocking::StylesheetBlockingReadView for LiveRuntimeDomHo
self.borrow().document_node_id()
}
fn document_is_quirks_mode(&self) -> bool {
<DomHost as super::stylesheet_blocking::StylesheetBlockingReadView>::document_is_quirks_mode(
self.borrow(),
)
}
fn document_order_stylesheet_candidate_ids_before(
&self,
target_node_id: Option<NodeId>,
@@ -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(),
"<html><head><link rel=stylesheet href=app.css></head></html>".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;
@@ -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<crate::dom::NodeId>,
@@ -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#"<link rel="stylesheet" href="/app.css">"#,
&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");
@@ -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,
})
+153 -8
View File
@@ -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<crate::protocol_types::NavigationResponse, String> {
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();
+14 -1
View File
@@ -251,6 +251,7 @@ pub trait StylesheetBlockingReadView {
fn final_url_clone(&self) -> Option<Url>;
fn document_base_url_clone(&self) -> Option<Url>;
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<NodeId>,
@@ -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<NodeId>,
@@ -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 {
+14 -1
View File
@@ -19,7 +19,7 @@ use crate::types::{
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct StylesheetFetchOptions(Arc<StylesheetFetchOptionsData>);
#[derive(Debug, Default, PartialEq, Eq, Hash)]
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
struct StylesheetFetchOptionsData {
cross_origin: Option<String>,
referrer_policy: Option<String>,
@@ -27,6 +27,7 @@ struct StylesheetFetchOptionsData {
nonce: Option<String>,
charset: Option<String>,
fetch_priority: Option<String>,
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<String>,
integrity: Option<String>,
charset: Option<String>,
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(),
}
}
+29
View File
@@ -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<NodeId>,
@@ -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();