diff --git a/moli-core/src/config/mod.rs b/moli-core/src/config/mod.rs index 360c8c464..a50401ee9 100644 --- a/moli-core/src/config/mod.rs +++ b/moli-core/src/config/mod.rs @@ -12,6 +12,7 @@ pub struct BrowserConfig { optional_resource_fetch_mask: OptionalResourceFetchMask, subframe_loading_enabled: bool, script_execution_disabled: bool, + author_styles_disabled: bool, wpt_extensions_enabled: bool, } @@ -25,6 +26,7 @@ impl Default for BrowserConfig { optional_resource_fetch_mask: OptionalResourceFetchMask::NONE, subframe_loading_enabled: true, script_execution_disabled: false, + author_styles_disabled: false, wpt_extensions_enabled: false, } } @@ -135,6 +137,14 @@ impl BrowserConfig { self.script_execution_disabled = disabled; } + pub fn author_styles_disabled(&self) -> bool { + self.author_styles_disabled + } + + pub fn set_author_styles_disabled(&mut self, disabled: bool) { + self.author_styles_disabled = disabled; + } + pub fn wpt_extensions_enabled(&self) -> bool { self.wpt_extensions_enabled } @@ -228,6 +238,15 @@ mod tests { assert!(config.script_execution_disabled()); } + #[test] + fn browser_config_defaults_to_author_styles_and_can_disable_them() { + let mut config = BrowserConfig::default(); + assert!(!config.author_styles_disabled()); + + config.set_author_styles_disabled(true); + assert!(config.author_styles_disabled()); + } + #[test] fn browser_config_toggles_each_optional_resource_bit_independently() { for (enabled_type, enabled_bit) in OPTIONAL_RESOURCES { diff --git a/moli-core/src/runtime/mod.rs b/moli-core/src/runtime/mod.rs index d6a18fa89..46ba7b367 100644 --- a/moli-core/src/runtime/mod.rs +++ b/moli-core/src/runtime/mod.rs @@ -251,6 +251,7 @@ impl Browser { let page_network_policy = PageNetworkPolicy::new( config.optional_resource_fetch_mask(), config.subframe_loading_enabled(), + config.author_styles_disabled(), ); // A script-disabled CLI fetch must not execute site-owned JavaScript // through a previously registered Service Worker before the new diff --git a/moli-core/src/runtime/navigation_engine.rs b/moli-core/src/runtime/navigation_engine.rs index ccdb3fc2d..5c00e6085 100644 --- a/moli-core/src/runtime/navigation_engine.rs +++ b/moli-core/src/runtime/navigation_engine.rs @@ -580,6 +580,7 @@ impl NavigationEngine { page_network_policy: PageNetworkPolicy::new( optional_resource_fetch_mask, subframe_loading_enabled, + false, ), layout_policy, js_runtime, @@ -629,6 +630,7 @@ impl NavigationEngine { page_network_policy: PageNetworkPolicy::new( optional_resource_fetch_mask, subframe_loading_enabled, + false, ), layout_policy, js_runtime: renderer_owner_source.js_runtime.clone(), diff --git a/moli-renderer-v8/src/document_runtime.rs b/moli-renderer-v8/src/document_runtime.rs index e1843478b..69adbc96a 100644 --- a/moli-renderer-v8/src/document_runtime.rs +++ b/moli-renderer-v8/src/document_runtime.rs @@ -758,6 +758,7 @@ pub(super) struct DocumentRuntime { document: HostDocumentState, design_mode_documents: HashSet, script_execution_control: crate::script_execution_control::RendererScriptExecutionControl, + author_styles_disabled: bool, bypass_content_security_policy: bool, policy_container: DocumentPolicyContainer, delivered_meta_content_security_policies: RefCell>>, diff --git a/moli-renderer-v8/src/document_runtime/mutation_commands.rs b/moli-renderer-v8/src/document_runtime/mutation_commands.rs index c2168c36f..c210f8fff 100644 --- a/moli-renderer-v8/src/document_runtime/mutation_commands.rs +++ b/moli-renderer-v8/src/document_runtime/mutation_commands.rs @@ -1824,6 +1824,9 @@ pub(super) fn finish_runtime_mutation_effects( ); } for prepared_owner_change in prepared_owner_changes { + if runtime.author_styles_disabled() { + continue; + } let owner = prepared_owner_change.owner(); if let Some(url) = prepared_owner_change.cached_linked_stylesheet_url() { let _ = unsafe { &mut *host_ptr } diff --git a/moli-renderer-v8/src/document_runtime/runtime_core.rs b/moli-renderer-v8/src/document_runtime/runtime_core.rs index 730f98202..be7e9c873 100644 --- a/moli-renderer-v8/src/document_runtime/runtime_core.rs +++ b/moli-renderer-v8/src/document_runtime/runtime_core.rs @@ -75,6 +75,7 @@ impl DocumentRuntime { document, design_mode_documents: HashSet::new(), script_execution_control: Default::default(), + author_styles_disabled: false, bypass_content_security_policy: false, policy_container: DocumentPolicyContainer::default(), delivered_meta_content_security_policies: RefCell::new(HashMap::new()), @@ -161,6 +162,7 @@ impl DocumentRuntime { .script_lifecycle .retain_standalone_parser_boundary_lifecycle_source(parser_boundary_lifecycle_source); if let Some((loader, task_runner)) = resource_environment { + runtime.set_author_styles_disabled(loader.author_styles_disabled()); let document_url = runtime .dom_host .borrow() @@ -204,6 +206,7 @@ impl DocumentRuntime { document: _, design_mode_documents: _, script_execution_control: _, + author_styles_disabled: _, bypass_content_security_policy: _, policy_container: _, delivered_meta_content_security_policies: _, @@ -271,6 +274,14 @@ impl DocumentRuntime { self.script_execution_control.is_disabled() } + pub(crate) fn set_author_styles_disabled(&mut self, disabled: bool) { + self.author_styles_disabled = disabled; + } + + pub(crate) fn author_styles_disabled(&self) -> bool { + self.author_styles_disabled + } + pub(crate) fn document_scripting_enabled(&self) -> bool { !self.script_execution_disabled() && self.document_sandbox_policy().allows_scripts } diff --git a/moli-renderer-v8/src/native_bridge/context_host/core.rs b/moli-renderer-v8/src/native_bridge/context_host/core.rs index 752aec446..4e92b9909 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/core.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/core.rs @@ -344,7 +344,9 @@ impl JsContextHost { child_meta_refresh_navigations: HashMap::new(), disconnected_shadow_roots: HashSet::new(), live_stylesheets: crate::live_stylesheet::LiveStylesheetRegistry::default(), - style_engine: MoliStyleEngine::new(), + style_engine: MoliStyleEngine::new_with_author_styles_disabled( + runtime.author_styles_disabled(), + ), inline_style_declarations: HashMap::new(), css_module_texts_by_url: HashMap::new(), css_module_failed_urls: HashSet::new(), diff --git a/moli-renderer-v8/src/native_bridge/context_host/host_environment.rs b/moli-renderer-v8/src/native_bridge/context_host/host_environment.rs index 1910065fd..7e107a6e5 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/host_environment.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/host_environment.rs @@ -1224,6 +1224,9 @@ impl JsContextHost { &self, owner: DomHandle, ) -> Option> { + if self.author_styles_disabled() { + return None; + } let dom_host = self.dom_host(); let element = dom_host.node(owner).and_then(Node::as_element)?; if !dom_host.is_connected(owner) @@ -1366,18 +1369,20 @@ impl JsContextHost { ) -> Option { let document_context = self.style_source_document_context_for_read_document(read_document); let inputs = inputs.independent_style_engine_projection(); - crate::style_engine::MoliStyleEngine::new() - .computed_style_property_value_with_document_context( - self.dom_host(), - &self.document_url_for_handle(read_document), - handle, - property, - None, - &inputs, - document_context.as_ref(), - read_document, - viewport, - ) + crate::style_engine::MoliStyleEngine::new_with_author_styles_disabled( + self.style_engine.author_styles_disabled(), + ) + .computed_style_property_value_with_document_context( + self.dom_host(), + &self.document_url_for_handle(read_document), + handle, + property, + None, + &inputs, + document_context.as_ref(), + read_document, + viewport, + ) } fn style_source_document_context(&self) -> OwnedStyleSourceDocumentContext { diff --git a/moli-renderer-v8/src/native_bridge/element/styles/declaration/style_world.rs b/moli-renderer-v8/src/native_bridge/element/styles/declaration/style_world.rs index 5cfc6e2ca..d4b340ed4 100644 --- a/moli-renderer-v8/src/native_bridge/element/styles/declaration/style_world.rs +++ b/moli-renderer-v8/src/native_bridge/element/styles/declaration/style_world.rs @@ -235,6 +235,9 @@ fn document_stylesheet_sources( context: StyleComputationContext, ) -> Vec { let mut sources = Vec::new(); + if runtime.author_styles_disabled() { + return sources; + } let Some(document) = source_document else { return sources; }; @@ -268,6 +271,9 @@ fn shadow_stylesheet_sources( root: DomHandle, context: StyleComputationContext, ) -> Vec { + if runtime.author_styles_disabled() { + return Vec::new(); + } #[cfg(test)] runtime.note_style_world_shadow_scope_materialization_for_test(); let mut sources = active_stylesheet_handles(runtime, root, context.read_document.is_some()) @@ -295,6 +301,9 @@ pub(super) fn active_stylesheet_handles( root: DomHandle, include_detached: bool, ) -> Vec { + if runtime.author_styles_disabled() { + return Vec::new(); + } let mut handles = runtime .dom_host() .stylesheet_candidate_handles_for_tree_scope(root) diff --git a/moli-renderer-v8/src/network/context/document.rs b/moli-renderer-v8/src/network/context/document.rs index 3d0419e66..b1d344b89 100644 --- a/moli-renderer-v8/src/network/context/document.rs +++ b/moli-renderer-v8/src/network/context/document.rs @@ -98,6 +98,10 @@ impl DocumentResourceLoaderBootstrap { pub(crate) fn commit(self, context: DocumentFetchContext) -> DocumentResourceLoader { DocumentResourceLoader::new(self.request_client, self.task_runner, context) } + + pub(crate) fn author_styles_disabled(&self) -> bool { + self.request_client.author_styles_disabled() + } } /// Exact backend source selected when a new Document commits. diff --git a/moli-renderer-v8/src/network/policy.rs b/moli-renderer-v8/src/network/policy.rs index bd612c7ce..97de46171 100644 --- a/moli-renderer-v8/src/network/policy.rs +++ b/moli-renderer-v8/src/network/policy.rs @@ -32,6 +32,7 @@ struct PageNetworkPolicyState { blocked_url_patterns: SharedPatternList, optional_resource_fetch_mask: OptionalResourceFetchMask, subframe_loading_enabled: bool, + author_styles_disabled: bool, bypass_service_worker: bool, cache_disabled: bool, } @@ -54,6 +55,7 @@ impl Default for PageNetworkPolicyState { blocked_url_patterns: Arc::from([]), optional_resource_fetch_mask: OptionalResourceFetchMask::NONE, subframe_loading_enabled: true, + author_styles_disabled: false, bypass_service_worker: false, cache_disabled: false, } @@ -105,10 +107,12 @@ impl PageNetworkPolicy { pub fn new( optional_resource_fetch_mask: OptionalResourceFetchMask, subframe_loading_enabled: bool, + author_styles_disabled: bool, ) -> Self { let state = PageNetworkPolicyState { optional_resource_fetch_mask, subframe_loading_enabled, + author_styles_disabled, ..PageNetworkPolicyState::default() }; Self { @@ -162,6 +166,7 @@ impl PageNetworkPolicy { blocked_url_patterns: snapshot.blocked_url_patterns, optional_resource_fetch_mask: snapshot.optional_resource_fetch_mask, subframe_loading_enabled: snapshot.subframe_loading_enabled, + author_styles_disabled: snapshot.author_styles_disabled, bypass_service_worker: snapshot.bypass_service_worker, cache_disabled: snapshot.cache_disabled, })), @@ -189,6 +194,7 @@ impl PageNetworkPolicy { blocked_url_patterns: snapshot.blocked_url_patterns, optional_resource_fetch_mask: snapshot.optional_resource_fetch_mask, subframe_loading_enabled: snapshot.subframe_loading_enabled, + author_styles_disabled: snapshot.author_styles_disabled, bypass_service_worker: snapshot.bypass_service_worker, cache_disabled: snapshot.cache_disabled, })), @@ -222,6 +228,7 @@ impl PageNetworkPolicy { blocked_url_patterns: state.blocked_url_patterns.clone(), optional_resource_fetch_mask: state.optional_resource_fetch_mask, subframe_loading_enabled: state.subframe_loading_enabled, + author_styles_disabled: state.author_styles_disabled, bypass_service_worker: state.bypass_service_worker, cache_disabled: state.cache_disabled, } @@ -322,6 +329,10 @@ impl PageNetworkPolicy { self.state.lock().subframe_loading_enabled } + pub fn author_styles_disabled(&self) -> bool { + self.state.lock().author_styles_disabled + } + pub fn set_bypass_service_worker(&self, bypass: bool) { let mut state = self.state.lock(); if state.bypass_service_worker == bypass { @@ -362,6 +373,7 @@ pub struct PageNetworkPolicySnapshot { blocked_url_patterns: SharedPatternList, optional_resource_fetch_mask: OptionalResourceFetchMask, subframe_loading_enabled: bool, + author_styles_disabled: bool, bypass_service_worker: bool, cache_disabled: bool, } @@ -384,6 +396,10 @@ impl PageNetworkPolicySnapshot { self.subframe_loading_enabled } + pub fn author_styles_disabled(&self) -> bool { + self.author_styles_disabled + } + pub fn bypass_service_worker(&self) -> bool { self.bypass_service_worker } @@ -409,6 +425,15 @@ impl PageNetworkPolicySnapshot { if self.blocks_url(&request.url) { return Err(anyhow!(BLOCKED_BY_CLIENT_ERROR_TEXT)); } + if self.author_styles_disabled + && matches!( + request.resource_type, + moli_fetch::RequestResourceType::CssStyleSheet + | moli_fetch::RequestResourceType::LatePreloadCssStyleSheet + ) + { + return Err(anyhow!(BLOCKED_BY_CLIENT_ERROR_TEXT)); + } if self.cache_disabled { request = request.with_cache_mode(RequestCacheMode::Bypass); @@ -454,7 +479,7 @@ mod tests { #[test] fn isolated_policy_copy_preserves_values_without_sharing_mutations() { - let policy = PageNetworkPolicy::new(OptionalResourceFetchMask::IMAGE, false); + let policy = PageNetworkPolicy::new(OptionalResourceFetchMask::IMAGE, false, true); policy.set_extra_http_headers(&[("x-owner".to_owned(), "first".to_owned())]); let isolated = policy.isolated_copy(); @@ -464,6 +489,7 @@ mod tests { OptionalResourceFetchMask::IMAGE ); assert!(!isolated.subframe_loading_enabled()); + assert!(isolated.author_styles_disabled()); isolated.set_network_offline(true); isolated.set_extra_http_headers(&[("x-owner".to_owned(), "second".to_owned())]); @@ -483,6 +509,41 @@ mod tests { ); } + #[test] + fn author_style_policy_blocks_only_stylesheet_requests() { + let policy = PageNetworkPolicy::new(OptionalResourceFetchMask::NONE, true, true); + + for resource_type in [ + moli_fetch::RequestResourceType::CssStyleSheet, + moli_fetch::RequestResourceType::LatePreloadCssStyleSheet, + ] { + let error = policy + .snapshot() + .apply_to_request( + Request::get("https://example.test/page.css") + .unwrap() + .with_resource_type(resource_type) + .with_page_network_policy(), + ) + .expect_err("disabled author styles must block CSS requests"); + assert_eq!(error.to_string(), BLOCKED_BY_CLIENT_ERROR_TEXT); + } + + let script = policy + .snapshot() + .apply_to_request( + Request::get("https://example.test/page.js") + .unwrap() + .with_resource_type(moli_fetch::RequestResourceType::Script) + .with_page_network_policy(), + ) + .expect("disabling styles must not block scripts"); + assert_eq!( + script.resource_type, + moli_fetch::RequestResourceType::Script + ); + } + #[test] fn request_snapshot_does_not_observe_later_policy_mutation() { let policy = PageNetworkPolicy::default(); diff --git a/moli-renderer-v8/src/network/request_client.rs b/moli-renderer-v8/src/network/request_client.rs index e45797a26..d7d4cf454 100644 --- a/moli-renderer-v8/src/network/request_client.rs +++ b/moli-renderer-v8/src/network/request_client.rs @@ -1027,6 +1027,10 @@ impl ResourceRequestClient { self.page_network_policy.subframe_loading_enabled() } + pub fn author_styles_disabled(&self) -> bool { + self.page_network_policy.author_styles_disabled() + } + pub fn set_bypass_service_worker(&self, bypass: bool) { self.page_network_policy.set_bypass_service_worker(bypass); } diff --git a/moli-renderer-v8/src/runtime/page_vm/mod.rs b/moli-renderer-v8/src/runtime/page_vm/mod.rs index 01920fd9c..aba82235e 100644 --- a/moli-renderer-v8/src/runtime/page_vm/mod.rs +++ b/moli-renderer-v8/src/runtime/page_vm/mod.rs @@ -1520,6 +1520,7 @@ impl PageVmRuntimeHooks { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum ScannedStylesheetAdmission { Admitted, + SuppressedByPagePolicy, DeferredToParser(ScannedStylesheetDeferral), } @@ -4451,6 +4452,9 @@ impl PageVm { request_resource_type: moli_fetch::RequestResourceType, link_preload: bool, ) -> ScannedStylesheetAdmission { + if self.vm().document_runtime.author_styles_disabled() { + return ScannedStylesheetAdmission::SuppressedByPagePolicy; + } if self.vm().fetch_subresource_interception_matches( crate::types::SubresourceResourceType::Stylesheet, ) { @@ -4490,6 +4494,9 @@ impl PageVm { link_preload, ) { Ok(_) => ScannedStylesheetAdmission::Admitted, + Err( + crate::document_runtime::OwnerlessStylesheetAdmissionError::AuthorStylesDisabled, + ) => ScannedStylesheetAdmission::SuppressedByPagePolicy, Err( crate::document_runtime::OwnerlessStylesheetAdmissionError::ContentSecurityPolicy, ) => ScannedStylesheetAdmission::DeferredToParser( diff --git a/moli-renderer-v8/src/runtime/script_preloads.rs b/moli-renderer-v8/src/runtime/script_preloads.rs index d7b19e3d8..af49c7ea5 100644 --- a/moli-renderer-v8/src/runtime/script_preloads.rs +++ b/moli-renderer-v8/src/runtime/script_preloads.rs @@ -868,6 +868,7 @@ pub(super) fn admit_stylesheet_preloads( let timing_started = moli_trace::cdp_nav_timing_enabled().then(std::time::Instant::now); let discovered_count = requests.len(); let mut admitted_count = 0_usize; + let mut page_policy_suppressed_count = 0_usize; let mut fetch_interception_count = 0_usize; let mut media_mismatch_count = 0_usize; let mut content_security_policy_count = 0_usize; @@ -880,6 +881,9 @@ pub(super) fn admit_stylesheet_preloads( request.link_preload, ) { ScannedStylesheetAdmission::Admitted => admitted_count += 1, + ScannedStylesheetAdmission::SuppressedByPagePolicy => { + page_policy_suppressed_count += 1; + } ScannedStylesheetAdmission::DeferredToParser( ScannedStylesheetDeferral::FetchInterception, ) => fetch_interception_count += 1, @@ -898,6 +902,7 @@ pub(super) fn admit_stylesheet_preloads( target: "moli_cdp_nav_timing", discovered_count, admitted_count, + page_policy_suppressed_count, fetch_interception_count, media_mismatch_count, content_security_policy_count, diff --git a/moli-renderer-v8/src/script_vm.rs b/moli-renderer-v8/src/script_vm.rs index 41adf587e..d5cdc2fb2 100644 --- a/moli-renderer-v8/src/script_vm.rs +++ b/moli-renderer-v8/src/script_vm.rs @@ -1915,6 +1915,7 @@ impl ScriptVmPageRealmBootstrap { let stylesheet_task_sender = page_task_tx.stylesheet_task_sender(); let main_parser_continuation_sender = page_task_tx.main_parser_continuation_sender(); let resource_owner_id = crate::resource_owner::ResourceOwnerId::new(); + let author_styles_disabled = initial_document_loader_bootstrap.author_styles_disabled(); let mut document_runtime = Box::new(DocumentRuntime::from_main_frame_dom_host( dom_host, main_document_owner, @@ -1923,6 +1924,7 @@ impl ScriptVmPageRealmBootstrap { stylesheet_task_sender, main_parser_continuation_sender, )); + document_runtime.set_author_styles_disabled(author_styles_disabled); document_runtime.set_bypass_content_security_policy(bypass_content_security_policy); let (page_context_cancel_tx, page_context_cancel_rx) = renderer_page_context_cancel_channel(); diff --git a/moli-renderer-v8/src/style_engine/computed.rs b/moli-renderer-v8/src/style_engine/computed.rs index 5c5d8453c..7161ee441 100644 --- a/moli-renderer-v8/src/style_engine/computed.rs +++ b/moli-renderer-v8/src/style_engine/computed.rs @@ -1222,6 +1222,7 @@ fn ensure_retained_style_system_for_computed_read( engine.invalidation_cleanup_for_world(world), key, inputs, + engine.author_styles_disabled(), ); } diff --git a/moli-renderer-v8/src/style_engine/mod.rs b/moli-renderer-v8/src/style_engine/mod.rs index 8939d3a5d..feee8acb4 100644 --- a/moli-renderer-v8/src/style_engine/mod.rs +++ b/moli-renderer-v8/src/style_engine/mod.rs @@ -162,6 +162,7 @@ pub(crate) use world_update::{ pub(crate) struct MoliStyleEngine { dom_adapter: StyloDomStyleAdapter, document_worlds: DocumentStyleWorlds, + author_styles_disabled: bool, owner_stylesheet_source_documents: RefCell>, linked_stylesheet_owner_documents: RefCell>, inline_style_metadata_documents: RefCell>, @@ -175,16 +176,25 @@ impl Default for MoliStyleEngine { impl MoliStyleEngine { pub(crate) fn new() -> Self { + Self::new_with_author_styles_disabled(false) + } + + pub(crate) fn new_with_author_styles_disabled(author_styles_disabled: bool) -> Self { ensure_stylo_browser_compat_prefs(); Self { dom_adapter: StyloDomStyleAdapter::new(), document_worlds: DocumentStyleWorlds::new(), + author_styles_disabled, owner_stylesheet_source_documents: RefCell::new(HashMap::new()), linked_stylesheet_owner_documents: RefCell::new(HashMap::new()), inline_style_metadata_documents: RefCell::new(HashMap::new()), } } + pub(crate) fn author_styles_disabled(&self) -> bool { + self.author_styles_disabled + } + pub(crate) fn author_shared_lock(&self) -> style::shared_lock::SharedRwLock { self.dom_adapter.shared_lock().clone() } diff --git a/moli-renderer-v8/src/style_engine/retained.rs b/moli-renderer-v8/src/style_engine/retained.rs index 6eef109b1..6f2155057 100644 --- a/moli-renderer-v8/src/style_engine/retained.rs +++ b/moli-renderer-v8/src/style_engine/retained.rs @@ -6,7 +6,7 @@ use style::{ servo_arc::Arc as ServoArc, shared_lock::{SharedRwLock, StylesheetGuards}, stylesheets::{CustomMediaMap, DocumentStyleSheet, Origin, OriginSet, UrlExtraData}, - stylist::Stylist, + stylist::{AuthorStylesEnabled, Stylist}, }; use crate::{document_runtime::DomHandle, dom::native::DomHost}; @@ -88,6 +88,7 @@ pub(super) fn build_retained_style_system( inputs: &FullStyleWorldSnapshot, shared_lock: &SharedRwLock, retained_source_records: &[RetainedStylesheetSourceRecord<'_>], + author_styles_disabled: bool, ) -> RetainedStyleSystem { let mut stylist = new_stylist_with_viewport_bits( key.viewport_width_bits, @@ -97,6 +98,11 @@ pub(super) fn build_retained_style_system( key.environment, key.quirks_mode, ); + stylist.set_author_styles_enabled(if author_styles_disabled { + AuthorStylesEnabled::No + } else { + AuthorStylesEnabled::Yes + }); register_script_custom_properties(&mut stylist, inputs); append_stylesheet_to_stylist( &mut stylist, diff --git a/moli-renderer-v8/src/style_engine/tests/lifecycle.rs b/moli-renderer-v8/src/style_engine/tests/lifecycle.rs index 986935e34..6d32b15ec 100644 --- a/moli-renderer-v8/src/style_engine/tests/lifecycle.rs +++ b/moli-renderer-v8/src/style_engine/tests/lifecycle.rs @@ -1,5 +1,52 @@ use super::*; +#[test] +fn disabled_author_styles_use_stylo_author_origin_gate() { + let mut host = test_host(); + host.reset_html_document_shell(); + let body = host.document_body_handle().expect("test body"); + let target = host.create_element("div"); + assert!(host.set_attribute(target, "class", "styled")); + assert!(host.set_attribute(target, "style", "color: rgb(1, 2, 3); display: none")); + assert!(host.append_child(body, target)); + + let document_url = url::Url::parse("https://example.test/").expect("test URL"); + let mut inputs = FullStyleWorldSnapshot::default(); + inputs + .document_stylesheet_sources + .push(StyloStylesheetSource::new( + ".styled { color: rgb(4, 5, 6); display: none; }".into(), + document_url.clone(), + )); + let engine = MoliStyleEngine::new_with_author_styles_disabled(true); + + assert_eq!( + engine.computed_style_property_value( + &host, + &document_url, + target, + "display", + None, + &inputs, + None, + ), + Some("block".to_owned()), + "UA styles must remain active while stylesheet and style-attribute declarations are ignored" + ); + assert_eq!( + engine.computed_style_property_value( + &host, + &document_url, + target, + "color", + None, + &inputs, + None, + ), + Some("rgb(0, 0, 0)".to_owned()) + ); +} + #[test] fn document_connected_shadow_scope_fallback_roots_include_shadow_roots() { let mut host = test_host(); diff --git a/moli-renderer-v8/src/style_engine/world_lifecycle.rs b/moli-renderer-v8/src/style_engine/world_lifecycle.rs index 41f13079d..d0c7becfd 100644 --- a/moli-renderer-v8/src/style_engine/world_lifecycle.rs +++ b/moli-renderer-v8/src/style_engine/world_lifecycle.rs @@ -90,6 +90,7 @@ pub(super) fn ensure_retained_style_system( invalidation_cleanup: StyleInvalidationCleanup<'_>, key: &StyleWorldKey, inputs: &FullStyleWorldSnapshot, + author_styles_disabled: bool, ) { let source_dirty_scope = document_state.source_dirty_scope_snapshot(); if document_state @@ -174,6 +175,7 @@ pub(super) fn ensure_retained_style_system( inputs, &shared_lock, &retained_source_records, + author_styles_disabled, ); if trace_enabled { trace_retained_style_system_change( diff --git a/moli-renderer-v8/src/stylesheet_runtime/blocking.rs b/moli-renderer-v8/src/stylesheet_runtime/blocking.rs index 883bea07c..d73f8258a 100644 --- a/moli-renderer-v8/src/stylesheet_runtime/blocking.rs +++ b/moli-renderer-v8/src/stylesheet_runtime/blocking.rs @@ -17,6 +17,7 @@ use crate::types::SubresourceResourceType; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum OwnerlessStylesheetAdmissionError { + AuthorStylesDisabled, ContentSecurityPolicy, } @@ -43,6 +44,9 @@ impl DocumentRuntime { request_resource_type: moli_fetch::RequestResourceType, link_preload: bool, ) -> Result { + if self.author_styles_disabled() { + return Err(OwnerlessStylesheetAdmissionError::AuthorStylesDisabled); + } let (_, enforced_violation) = self .response_style_element_request_csp_check( &request_url, @@ -142,6 +146,9 @@ impl DocumentRuntime { document_url: &url::Url, inputs: impl IntoIterator, ) -> Vec { + if self.author_styles_disabled() { + return Vec::new(); + } let inputs = inputs .into_iter() .filter(|input| { diff --git a/moli-renderer-v8/src/stylesheet_runtime/connected.rs b/moli-renderer-v8/src/stylesheet_runtime/connected.rs index f03337b4e..a3959425c 100644 --- a/moli-renderer-v8/src/stylesheet_runtime/connected.rs +++ b/moli-renderer-v8/src/stylesheet_runtime/connected.rs @@ -228,9 +228,15 @@ impl DocumentRuntime { if self.initial_connected_style_loads_queued { return Vec::new(); } + self.initial_connected_style_loads_queued = true; + if self.author_styles_disabled() { + self.stylesheet_lifecycle + .pre_initial_scan_processed_owners + .clear(); + return Vec::new(); + } self.late_preload_stylesheet_handles = self.collect_late_preload_stylesheet_handles_for_initial_scan(self.document_handle()); - self.initial_connected_style_loads_queued = true; let prepared = self.prepare_connected_style_loads(self.document_handle(), true); self.stylesheet_lifecycle .pre_initial_scan_processed_owners @@ -309,7 +315,7 @@ impl DocumentRuntime { root: DomHandle, skip_existing: bool, ) -> Vec { - if !self.dom_host.is_connected(root) { + if self.author_styles_disabled() || !self.dom_host.is_connected(root) { return Vec::new(); } let load_handles = if self.dom_host.node(root).is_some_and(Node::is_document) @@ -1431,6 +1437,9 @@ impl DocumentRuntime { host_ptr: *mut JsContextHost, owner: DomHandle, ) { + if self.author_styles_disabled() { + return; + } self.queue_stylesheet_source_css_projection(owner); if let Some(stylesheet) = unsafe { &*host_ptr }.owner_live_stylesheet(owner) { self.prime_live_stylesheet_import_loads(owner, stylesheet, false, host_ptr); @@ -1442,6 +1451,9 @@ impl DocumentRuntime { host_ptr: *mut JsContextHost, owner: DomHandle, ) { + if self.author_styles_disabled() { + return; + } self.queue_stylesheet_source_css_projection(owner); if let Some(stylesheet) = unsafe { &*host_ptr }.linked_live_stylesheet(owner) { self.prime_live_stylesheet_import_loads(owner, stylesheet, true, host_ptr); @@ -1463,6 +1475,10 @@ impl DocumentRuntime { scope: &mut v8::PinScope<'_, '_>, host_ptr: *mut JsContextHost, ) { + if self.author_styles_disabled() { + self.pending_stylesheet_source_css_projection_owners.clear(); + return; + } let owners = std::mem::take(&mut self.pending_stylesheet_source_css_projection_owners); for owner in owners { crate::native_bridge::document::apply_stylesheet_source_css_projection( diff --git a/moli/src/cli.rs b/moli/src/cli.rs index 3d83eb407..76843cd7e 100644 --- a/moli/src/cli.rs +++ b/moli/src/cli.rs @@ -83,6 +83,12 @@ pub struct FetchArgs { )] pub disable_js: bool, + /// Disable page-authored CSS before the Document starts parsing. External + /// stylesheets and `@import` resources are not fetched; style/link nodes + /// and style attributes remain in the DOM. + #[arg(long)] + pub disable_css: bool, + #[arg(long)] pub with_base: bool, diff --git a/moli/src/config.rs b/moli/src/config.rs index df3bd024d..7e5345459 100644 --- a/moli/src/config.rs +++ b/moli/src/config.rs @@ -44,6 +44,7 @@ impl AppConfig { config .browser .set_script_execution_disabled(args.disable_js); + config.browser.set_author_styles_disabled(args.disable_css); config.fetch.dump_mode = args.dump; config.fetch.strip = args.strip_options(); config.fetch.with_base = args.with_base; diff --git a/moli/tests/cli.rs b/moli/tests/cli.rs index 2e9f074db..32a78bb10 100644 --- a/moli/tests/cli.rs +++ b/moli/tests/cli.rs @@ -35,6 +35,7 @@ fn parses_explicit_fetch_command_with_compatibility_flags() { "-H", "X-Trace: two", "--disable-js", + "--disable-css", "--with-base", "--with-frames", "--strip-mode", @@ -87,6 +88,7 @@ fn parses_explicit_fetch_command_with_compatibility_flags() { }, ], disable_js: true, + disable_css: true, with_base: true, with_frames: true, trace_network: false, @@ -422,6 +424,7 @@ fn infers_fetch_mode_from_bare_url() { eval_file: None, headers: vec![], disable_js: false, + disable_css: false, with_base: false, with_frames: false, trace_network: false, @@ -464,6 +467,7 @@ fn parses_bare_dump_with_explicit_fetch_command_and_defaults_to_html() { eval_file: None, headers: vec![], disable_js: false, + disable_css: false, with_base: false, with_frames: false, trace_network: false, @@ -510,6 +514,7 @@ fn parses_header_flag_with_explicit_fetch_command() { value: "one".to_owned(), }], disable_js: false, + disable_css: false, with_base: false, with_frames: false, trace_network: false, @@ -1476,6 +1481,36 @@ fn app_config_from_fetch_cli_disables_script_execution_only_when_requested() { ); } +#[test] +fn app_config_from_fetch_cli_disables_author_styles_only_when_requested() { + let enabled = Cli::try_parse_from(normalize_args_for_compat([ + "moli", + "fetch", + "https://example.com", + ])) + .unwrap(); + assert!( + !AppConfig::from_cli(&enabled) + .unwrap() + .browser + .author_styles_disabled() + ); + + let disabled = Cli::try_parse_from(normalize_args_for_compat([ + "moli", + "fetch", + "--disable-css", + "https://example.com", + ])) + .unwrap(); + assert!( + AppConfig::from_cli(&disabled) + .unwrap() + .browser + .author_styles_disabled() + ); +} + #[test] fn app_config_from_serve_cli_enables_image_fetch() { let cli = Cli::try_parse_from(normalize_args_for_compat(["moli", "serve", "--image"])).unwrap(); diff --git a/moli/tests/fetch_cli.rs b/moli/tests/fetch_cli.rs index 6294da652..68e189f11 100644 --- a/moli/tests/fetch_cli.rs +++ b/moli/tests/fetch_cli.rs @@ -40,6 +40,8 @@ use tracing_subscriber::fmt::MakeWriter; #[path = "fetch_cli/anubis_deferred_module.rs"] mod anubis_deferred_module; +#[path = "fetch_cli/disable_css.rs"] +mod disable_css; #[path = "fetch_cli/disable_js.rs"] mod disable_js; #[path = "fetch_cli/eval.rs"] diff --git a/moli/tests/fetch_cli/disable_css.rs b/moli/tests/fetch_cli/disable_css.rs new file mode 100644 index 000000000..24b7093af --- /dev/null +++ b/moli/tests/fetch_cli/disable_css.rs @@ -0,0 +1,374 @@ +use super::{Output, clean_output, run_fetch_cli_with_args}; +use anyhow::Result; +use axum::{ + Router, + extract::State, + http::{StatusCode, Uri}, + response::{Html, IntoResponse, Response}, + routing::get, +}; +use parking_lot::Mutex; +use std::sync::Arc; +use tokio::{net::TcpListener, task::JoinHandle}; + +const CSS_PATHS: [&str; 12] = [ + "/external.css", + "/external-import.css", + "/preload.css", + "/inline-import.css", + "/dynamic.css", + "/dynamic-import.css", + "/shadow-import.css", + "/child.css", + "/grandchild.css", + "/srcdoc.css", + "/rewrite.css", + "/rewrite-import.css", +]; + +const MAIN_PAGE: &str = r#" + + + + + + + + + +
adopted
+
dynamic
+
+ + + + +"#; + +const CHILD_PAGE: &str = r#" + + + + +"#; + +const GRANDCHILD_PAGE: &str = r#" + + +"#; + +const DISABLED_READY_SCRIPT: &str = r#"(() => { + const child = document.getElementById('child')?.contentDocument; + const grandchild = child?.getElementById('grandchild')?.contentDocument; + const srcdoc = document.getElementById('srcdoc-child')?.contentDocument; + const shadow = document.getElementById('shadow-host')?.shadowRoot; + const targets = [ + [window, document.getElementById('main-target'), 'block'], + [window, document.getElementById('adopted-target'), 'block'], + [window, document.getElementById('dynamic-target'), 'block'], + [window, shadow?.getElementById('shadow-target'), 'inline'], + [child?.defaultView, child?.getElementById('child-target'), 'block'], + [grandchild?.defaultView, grandchild?.getElementById('grandchild-target'), 'block'], + [srcdoc?.defaultView, srcdoc?.getElementById('srcdoc-target'), 'block'], + ]; + if (!targets.every(([view, target]) => view && target)) { + return false; + } + const authorStylesAreAbsent = targets.every(([view, target, display]) => { + const style = view.getComputedStyle(target); + return style.display === display && style.color === 'rgb(0, 0, 0)'; + }); + const scriptsRan = [document, child, grandchild, srcdoc].every( + targetDocument => targetDocument?.documentElement?.getAttribute('data-script-ran') === 'yes' + ); + if (!authorStylesAreAbsent || !scriptsRan) { + return false; + } + document.documentElement.setAttribute('data-disable-css-probe', 'ready'); + return true; +})()"#; + +struct DisableCssFixtureServer { + base_url: String, + requests: Arc>>, + task: JoinHandle<()>, +} + +impl DisableCssFixtureServer { + async fn spawn() -> Result { + let requests = Arc::new(Mutex::new(Vec::new())); + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let app = Router::new() + .route("/page.html", get(|| async { Html(MAIN_PAGE) })) + .route("/child.html", get(|| async { Html(CHILD_PAGE) })) + .route("/grandchild.html", get(|| async { Html(GRANDCHILD_PAGE) })) + .route("/baseline.html", get(baseline_page)) + .route("/redirect.html", get(redirect_to_page)) + .route("/document-open.html", get(document_open_page)) + .fallback(css_resource) + .with_state(Arc::clone(&requests)); + let task = tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("disable-css fixture server should serve"); + }); + Ok(Self { + base_url: format!("http://{addr}"), + requests, + task, + }) + } + + fn url(&self, path: &str) -> String { + format!("{}{path}", self.base_url) + } + + fn request_count(&self, path: &str) -> usize { + self.requests + .lock() + .iter() + .filter(|request| request.as_str() == path) + .count() + } +} + +impl Drop for DisableCssFixtureServer { + fn drop(&mut self) { + self.task.abort(); + } +} + +async fn baseline_page() -> Html<&'static str> { + Html( + r#" + +
baseline
"#, + ) +} + +async fn redirect_to_page() -> Response { + ( + StatusCode::FOUND, + [("location", "/page.html"), ("cache-control", "no-store")], + "", + ) + .into_response() +} + +async fn document_open_page() -> Html<&'static str> { + Html( + r#" + "#, + ) +} + +async fn css_resource(State(requests): State>>>, uri: Uri) -> Response { + let path = uri.path().to_owned(); + requests.lock().push(path.clone()); + let body = match path.as_str() { + "/external.css" => { + "@import url('/external-import.css'); .external { color: rgb(51, 52, 53); }" + } + "/external-import.css" => "#main-target { display: none; }", + "/child.css" => "#child-target { color: rgb(61, 62, 63); }", + "/grandchild.css" => "#grandchild-target { color: rgb(71, 72, 73); }", + _ if path.ends_with(".css") => "body { color: rgb(81, 82, 83); }", + _ => return (StatusCode::NOT_FOUND, "not found").into_response(), + }; + ( + StatusCode::OK, + [ + ("content-type", "text/css; charset=utf-8"), + ("cache-control", "no-store"), + ], + body, + ) + .into_response() +} + +fn assert_success(output: &Output, requests: &[String], scenario: &str) -> String { + assert!( + output.status.success(), + "{scenario} failed: stdout={}\nstderr={}\nrequests={requests:?}", + clean_output(&output.stdout), + clean_output(&output.stderr), + ); + clean_output(&output.stdout) +} + +fn assert_no_css_requests(server: &DisableCssFixtureServer, scenario: &str) { + for path in CSS_PATHS { + assert_eq!( + server.request_count(path), + 0, + "{scenario} unexpectedly requested {path}; requests={:?}", + server.requests.lock().as_slice() + ); + } +} + +#[test] +fn disables_all_author_style_surfaces_without_disabling_scripts() -> Result<()> { + let runtime = tokio::runtime::Runtime::new()?; + let server = runtime.block_on(DisableCssFixtureServer::spawn())?; + let output = run_fetch_cli_with_args( + &server.url("/page.html"), + &[ + "--disable-css", + "--with-frames", + "--timeout", + "5000", + "--wait-script", + DISABLED_READY_SCRIPT, + ], + )?; + let stdout = assert_success( + &output, + server.requests.lock().as_slice(), + "disabled author styles", + ); + + assert!( + stdout.contains("data-disable-css-probe=\"ready\""), + "automation must observe UA-only computed styles: {stdout}" + ); + assert!( + stdout.contains("data-script-ran=\"yes\""), + "--disable-css must not disable page JavaScript: {stdout}" + ); + for preserved in [ + "id=\"external-link\"", + "id=\"inline-sheet\"", + "style=\"color: rgb(4, 5, 6); display: none\"", + ] { + assert!( + stdout.contains(preserved), + "disabled CSS must remain represented in the DOM ({preserved}): {stdout}" + ); + } + assert_no_css_requests(&server, "--disable-css"); + Ok(()) +} + +#[test] +fn survives_http_redirect_without_losing_the_page_policy() -> Result<()> { + let runtime = tokio::runtime::Runtime::new()?; + let server = runtime.block_on(DisableCssFixtureServer::spawn())?; + let output = run_fetch_cli_with_args( + &server.url("/redirect.html"), + &[ + "--disable-css", + "--with-frames", + "--timeout", + "5000", + "--wait-script", + DISABLED_READY_SCRIPT, + ], + )?; + let stdout = assert_success( + &output, + server.requests.lock().as_slice(), + "redirected disabled author styles", + ); + + assert!(stdout.contains("data-disable-css-probe=\"ready\"")); + assert_no_css_requests(&server, "redirected --disable-css"); + Ok(()) +} + +#[test] +fn survives_document_open_replacement() -> Result<()> { + const READY_SCRIPT: &str = r#"(() => { + const target = document.getElementById('rewrite-target'); + if (!target || document.documentElement.getAttribute('data-rewrite-script-ran') !== 'yes') { + return false; + } + const style = getComputedStyle(target); + const ready = style.display === 'block' && style.color === 'rgb(0, 0, 0)'; + if (ready) document.documentElement.setAttribute('data-rewrite-css-probe', 'ready'); + return ready; + })()"#; + + let runtime = tokio::runtime::Runtime::new()?; + let server = runtime.block_on(DisableCssFixtureServer::spawn())?; + let output = run_fetch_cli_with_args( + &server.url("/document-open.html"), + &[ + "--disable-css", + "--timeout", + "5000", + "--wait-script", + READY_SCRIPT, + ], + )?; + let stdout = assert_success( + &output, + server.requests.lock().as_slice(), + "document.open disabled author styles", + ); + + assert!(stdout.contains("data-rewrite-css-probe=\"ready\"")); + assert!(stdout.contains("id=\"rewrite-link\"")); + assert_no_css_requests(&server, "document.open --disable-css"); + Ok(()) +} + +#[test] +fn author_styles_remain_enabled_by_default() -> Result<()> { + const READY_SCRIPT: &str = r#"(() => { + const target = document.getElementById('main-target'); + if (!target) return false; + const ready = getComputedStyle(target).color === 'rgb(51, 52, 53)'; + if (ready) document.documentElement.setAttribute('data-css-probe', 'ready'); + return ready; + })()"#; + + let runtime = tokio::runtime::Runtime::new()?; + let server = runtime.block_on(DisableCssFixtureServer::spawn())?; + let output = run_fetch_cli_with_args( + &server.url("/baseline.html"), + &["--timeout", "5000", "--wait-script", READY_SCRIPT], + )?; + let stdout = assert_success( + &output, + server.requests.lock().as_slice(), + "default author styles", + ); + + assert!(stdout.contains("data-css-probe=\"ready\"")); + assert_eq!(server.request_count("/external.css"), 1); + assert_eq!(server.request_count("/external-import.css"), 1); + Ok(()) +}