diff --git a/moli-benchmark/wpt-cross-current/failed-cases.txt b/moli-benchmark/wpt-cross-current/failed-cases.txt index 6e67eba1d4..9efea78cf9 100644 --- a/moli-benchmark/wpt-cross-current/failed-cases.txt +++ b/moli-benchmark/wpt-cross-current/failed-cases.txt @@ -30,7 +30,6 @@ content-security-policy/resource-hints/prefetch-generate-directives.html content-security-policy/resource-hints/prefetch-no-csp.html content-security-policy/sandbox/autoplay-disabled-by-csp.html content-security-policy/sandbox/window-reuse-sandboxed.html -content-security-policy/script-src/non-nonceable-elements.html content-security-policy/script-src/nonce-enforce-blocked.html content-security-policy/script-src/script-src-trusted_types_eval_DedicatedWorker.html content-security-policy/securitypolicyviolation/blockeduri-inline.html diff --git a/moli-benchmark/wpt-cross-current/passed-cases.txt b/moli-benchmark/wpt-cross-current/passed-cases.txt index e6e8d0d890..fbf1a60b47 100644 --- a/moli-benchmark/wpt-cross-current/passed-cases.txt +++ b/moli-benchmark/wpt-cross-current/passed-cases.txt @@ -211,6 +211,7 @@ content-security-policy/script-src/hash-always-converted-to-utf-8/iso-8859-9.htm content-security-policy/script-src/hash-always-converted-to-utf-8/utf-8-lone-surrogate.html content-security-policy/script-src/hash-always-converted-to-utf-8/utf-8.html content-security-policy/script-src/javascript-window-open-blocked.html +content-security-policy/script-src/non-nonceable-elements.html content-security-policy/script-src/script-src-1_1.html content-security-policy/script-src/script-src-1_10_1.html content-security-policy/script-src/script-src-1_2.html diff --git a/moli-parser/src/html.rs b/moli-parser/src/html.rs index 3314a257e6..92401f439a 100644 --- a/moli-parser/src/html.rs +++ b/moli-parser/src/html.rs @@ -373,6 +373,7 @@ pub(super) struct DocumentSink { // lines from the original document tail, so location fidelity only // degrades and never recovers for this parser session. source_positions_known: Cell, + current_script_nonceable: Cell>, html4_empty_system_id_quirks_override_pending: Cell, } @@ -1457,10 +1458,15 @@ impl DocumentSink { Self { target: RefCell::new(target), source_positions_known: Cell::new(true), + current_script_nonceable: Cell::new(None), html4_empty_system_id_quirks_override_pending: Cell::new(false), } } + pub(super) fn replace_current_script_nonceable(&self, nonceable: Option) -> Option { + self.current_script_nonceable.replace(nonceable) + } + pub(super) fn snapshot_parser_stream_document(&self) -> NativeDom { self.target.borrow().snapshot_parser_stream_document() } @@ -1681,7 +1687,12 @@ impl TreeSink for DocumentSink { attrs: Vec, flags: ElementFlags, ) -> Self::Handle { - self.target.borrow_mut().create_element(name, attrs, flags) + self.target.borrow_mut().create_element( + name, + attrs, + flags, + self.current_script_nonceable.get(), + ) } fn create_comment(&self, text: StrTendril) -> Self::Handle { diff --git a/moli-parser/src/lib.rs b/moli-parser/src/lib.rs index 855cc69c53..1704bcf8a6 100644 --- a/moli-parser/src/lib.rs +++ b/moli-parser/src/lib.rs @@ -79,6 +79,7 @@ struct HtmlTreeSinkState { captured_blocking_stylesheet_nodes: HashSet, captured_blocking_stylesheet_signatures: HashSet, finishing_tree_builder: bool, + non_nonceable_parser_scripts: HashSet, current_position: ParserSourcePosition, script_start_positions: HashMap, } @@ -99,6 +100,7 @@ impl Default for HtmlTreeSinkState { captured_blocking_stylesheet_nodes: HashSet::new(), captured_blocking_stylesheet_signatures: HashSet::new(), finishing_tree_builder: false, + non_nonceable_parser_scripts: HashSet::new(), current_position: ParserSourcePosition::default(), script_start_positions: HashMap::new(), } diff --git a/moli-parser/src/live_target.rs b/moli-parser/src/live_target.rs index 22c7e1a7eb..a88739165e 100644 --- a/moli-parser/src/live_target.rs +++ b/moli-parser/src/live_target.rs @@ -3210,6 +3210,7 @@ impl ParserStreamHtmlTreeSinkTarget { name: QualName, attrs: Vec, flags: ElementFlags, + script_nonceable: Option, ) -> ParseHandle { let parser_flags = ParserElementFlags::from_html5ever(&flags); let template_contents_insertion = self.take_template_contents_insertion_hint(); @@ -3287,6 +3288,7 @@ impl ParserStreamHtmlTreeSinkTarget { &attributes, has_null_custom_element_registry_attribute, parser_flags, + script_nonceable, ); } } @@ -3312,6 +3314,7 @@ impl ParserStreamHtmlTreeSinkTarget { &token_attributes, has_null_custom_element_registry_attribute, parser_flags, + script_nonceable, ) } @@ -3326,12 +3329,16 @@ impl ParserStreamHtmlTreeSinkTarget { token_attributes: &[NativeAttribute], has_null_custom_element_registry_attribute: bool, parser_flags: ParserElementFlags, + script_nonceable: Option, ) -> ParseHandle { if has_null_custom_element_registry_attribute { self.pending_null_custom_element_registry_elements .push(node_id); } if is_script { + if script_nonceable == Some(false) { + self.state.non_nonceable_parser_scripts.insert(node_id); + } self.state .script_start_positions .insert(node_id, self.state.current_position); @@ -3711,7 +3718,11 @@ pub(super) fn new_live_fragment_root_html_tree_sink_stream( impl ParserPlanningReadView for ParserStreamHtmlTreeSinkTarget { fn parser_script_read(&self, node_id: NativeNodeId) -> Option { - self.read_parser_script(node_id) + let mut script = self.read_parser_script(node_id)?; + if self.state.non_nonceable_parser_scripts.contains(&node_id) { + script.fetch_metadata.nonce = None; + } + Some(script) } fn script_handles(&self) -> Vec { diff --git a/moli-parser/src/script_planning.rs b/moli-parser/src/script_planning.rs index 240373158d..dd4c73a396 100644 --- a/moli-parser/src/script_planning.rs +++ b/moli-parser/src/script_planning.rs @@ -9,7 +9,7 @@ use moli_fetch::FetchPriorityHint; use moli_page_types::{ScriptKind, ScriptMode, ScriptSourceKind}; use moli_script::{ ScriptElementClassificationInput, ScriptPreparationClassificationInput, - ScriptPreparationDisposition, classify_script_preparation, + ScriptPreparationDisposition, classify_script_preparation, script_element_nonce_is_nonceable, }; pub struct ParserScriptRead { @@ -45,6 +45,19 @@ fn parser_script_read_from_node( // make a harmless `nomodule` suppress execution or make `defer` change the // parser lane. let is_html_script = element.is_html_script(); + let nonce = element + .cryptographic_nonce() + .or_else(|| element.attribute("nonce")); + let nonce = script_element_nonce_is_nonceable( + nonce, + false, + element + .attributes() + .iter() + .map(|attribute| (attribute.local_name(), attribute.value())), + ) + .then_some(nonce) + .flatten(); Some(ParserScriptRead { parser_inserted: node.flags().parser_created(), parser_inserted_for_prepare: element.script_parser_inserted_for_prepare(), @@ -76,9 +89,7 @@ fn parser_script_read_from_node( element.attribute("referrerpolicy"), element.attribute("charset"), element.attribute("integrity"), - element - .cryptographic_nonce() - .or_else(|| element.attribute("nonce")), + nonce, element.attribute("fetchpriority"), ) .with_parser_inserted(node.flags().parser_created()), diff --git a/moli-parser/src/session.rs b/moli-parser/src/session.rs index 9fa20921c3..ad019567d4 100644 --- a/moli-parser/src/session.rs +++ b/moli-parser/src/session.rs @@ -14,6 +14,7 @@ use html5ever::{ }; use markup5ever::TokenizerResult; use moli_dom::native::NativeNodeId; +use moli_script::script_element_nonce_is_nonceable; use super::{ html::{DocumentSink, ParseHandle, ParserFinishDiscoverySignals, ParserInputQueue}, @@ -82,6 +83,27 @@ impl EmbedderPausingTreeBuilder { token: Token, line_number: u64, ) -> TokenSinkResult { + let current_script_nonceable = match &token { + Token::TagToken(tag) + if tag.kind == TagKind::StartTag && tag.name.as_ref() == "script" => + { + let nonce = tag + .attrs + .iter() + .find(|attribute| { + attribute.name.ns.is_empty() && attribute.name.local.as_ref() == "nonce" + }) + .map(|attribute| attribute.value.as_ref()); + Some(script_element_nonce_is_nonceable( + nonce, + tag.had_duplicate_attributes, + tag.attrs + .iter() + .map(|attribute| (attribute.name.local.as_ref(), attribute.value.as_ref())), + )) + } + _ => None, + }; let in_foreign_content = self .inner .adjusted_current_node_present_but_not_in_html_namespace(); @@ -99,7 +121,14 @@ impl EmbedderPausingTreeBuilder { } _ => None, }; + let previous_script_nonceable = self + .inner + .sink + .replace_current_script_nonceable(current_script_nonceable); let result = self.inner.process_token(token, line_number); + self.inner + .sink + .replace_current_script_nonceable(previous_script_nonceable); // Encoding notices are advisory for this already-decoded input. A tag // such as can also require an // embedder pause, which must be returned at this tag boundary. Leaving diff --git a/moli-parser/src/stream.rs b/moli-parser/src/stream.rs index 6faf1e1fee..42d27855ed 100644 --- a/moli-parser/src/stream.rs +++ b/moli-parser/src/stream.rs @@ -1746,6 +1746,42 @@ mod tests { ); } + #[test] + fn parser_script_handoff_only_exposes_nonceable_nonces() { + fn handoff_nonce(markup: &str) -> Option { + let mut stream = DocumentStream::new_parser_stream_for_testing( + Url::parse("https://example.test/page.html").expect("test url"), + ); + let outcome = stream.pump_parser_step(markup); + let ParserPumpStep::Yield(ParserYield::Script(handoff)) = outcome.result else { + panic!("expected parser script handoff for {markup:?}"); + }; + let ParserScriptHandoff::BlockingClassic { script, .. } = *handoff else { + panic!("expected blocking classic script for {markup:?}"); + }; + script.fetch_metadata.nonce + } + + assert_eq!( + handoff_nonce(""), + Some("abc".to_owned()), + "ordinary parser script nonce should remain usable" + ); + for markup in [ + "", + "", + "", + "", + "", + ] { + assert_eq!( + handoff_nonce(markup), + None, + "nonnonceable parser script must not expose its nonce: {markup:?}" + ); + } + } + #[test] fn parser_script_handoff_uses_html5ever_line_with_unknown_column_across_input_chunks() { let stream = DocumentStream::new_scripting_enabled_parser_stream_for_testing( diff --git a/moli-parser/src/xml_stream.rs b/moli-parser/src/xml_stream.rs index db665ef400..a4ee1d5b3c 100644 --- a/moli-parser/src/xml_stream.rs +++ b/moli-parser/src/xml_stream.rs @@ -630,7 +630,7 @@ impl TreeSink for XmlStreamDocumentSink { XmlStreamParseHandle::element( target .common - .create_element(html_name, html_attrs, html_flags), + .create_element(html_name, html_attrs, html_flags, None), element_name, ) } diff --git a/moli-renderer-v8/src/document_runtime/mutation_commands.rs b/moli-renderer-v8/src/document_runtime/mutation_commands.rs index c89319904c..26150f8efa 100644 --- a/moli-renderer-v8/src/document_runtime/mutation_commands.rs +++ b/moli-renderer-v8/src/document_runtime/mutation_commands.rs @@ -2192,16 +2192,8 @@ fn execute_committed_inline_classic_script( committed: crate::host::CommittedInlineClassicScript, ) { let (node, host_script_handle, source) = committed.into_parts(); - let nonce = runtime - .dom_host - .node(node) - .and_then(Node::as_element) - .and_then(|element| { - element - .cryptographic_nonce() - .or_else(|| element.attribute("nonce")) - }) - .map(str::to_owned); + let nonce = + crate::host::script_element_nonce_for_csp(&runtime.dom_host, node).map(str::to_owned); let request = crate::content_security_policy::ContentSecurityPolicyScriptElementRequest { nonce: nonce.as_deref(), integrity: None, diff --git a/moli-renderer-v8/src/host.rs b/moli-renderer-v8/src/host.rs index ef8cec23d3..fe01728cc6 100644 --- a/moli-renderer-v8/src/host.rs +++ b/moli-renderer-v8/src/host.rs @@ -47,6 +47,7 @@ pub(super) use self::scripts::{ begin_prepared_document_write_script_start, build_runtime_prepared_script, cancel_runtime_script_start_admission, dispatch_script_event, finish_runtime_script_start_admission, plan_script_start, prepare_runtime_script_start_commit, + script_element_nonce_for_csp, }; #[cfg(test)] pub(super) use self::scripts::{ diff --git a/moli-renderer-v8/src/host/scripts/loader.rs b/moli-renderer-v8/src/host/scripts/loader.rs index 05d423e0aa..cbe5ab0fe5 100644 --- a/moli-renderer-v8/src/host/scripts/loader.rs +++ b/moli-renderer-v8/src/host/scripts/loader.rs @@ -10,7 +10,7 @@ use crate::{ }, }, }; -use moli_script::ScriptPreparationDisposition; +use moli_script::{ScriptPreparationDisposition, script_element_nonce_is_nonceable}; use url::Url; #[derive(Debug, Clone)] @@ -27,10 +27,7 @@ impl RuntimeScriptPreparationContext { node: NativeNodeId, ) -> RuntimeScriptPreparationContext { let script_element = dom_host.node(node).and_then(Node::as_element); - let nonce = script_element - .and_then(|element| element.cryptographic_nonce()) - .map(str::to_owned) - .or_else(|| dom_host.get_attribute(node, "nonce")); + let nonce = script_element_nonce_for_csp(dom_host, node).map(str::to_owned); let parser_inserted = script_element.is_some_and(|element| element.script_parser_inserted_for_prepare()); let owner_document = dom_host.owner_document_handle(node); @@ -56,6 +53,23 @@ impl RuntimeScriptPreparationContext { } } +pub(crate) fn script_element_nonce_for_csp(dom_host: &DomHost, node: NativeNodeId) -> Option<&str> { + let element = dom_host.node(node).and_then(Node::as_element)?; + let nonce = element + .cryptographic_nonce() + .or_else(|| element.attribute("nonce")); + script_element_nonce_is_nonceable( + nonce, + false, + element + .attributes() + .iter() + .map(|attribute| (attribute.local_name(), attribute.value())), + ) + .then_some(nonce) + .flatten() +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum RuntimeScriptStartDecision { Skip { diff --git a/moli-renderer-v8/src/host/scripts/tests.rs b/moli-renderer-v8/src/host/scripts/tests.rs index 120d25f6ad..7c9d25dbf2 100644 --- a/moli-renderer-v8/src/host/scripts/tests.rs +++ b/moli-renderer-v8/src/host/scripts/tests.rs @@ -886,6 +886,38 @@ fn runtime_preparation_capture_uses_live_document_base_url() { ); } +#[test] +fn runtime_preparation_only_captures_nonceable_script_nonces() { + let url = Url::parse("https://example.test/").expect("test url should parse"); + let document = HtmlParser.parse( + url.clone(), + "".to_owned(), + ); + let mut dom_host = DomHost::from_dom(document); + let document_state = HostDocumentState::new(url); + + let safe_script = dom_host.create_element("script"); + assert!(dom_host.set_attribute(safe_script, "nonce", "abc")); + assert_eq!( + RuntimeScriptPreparationContext::capture(&dom_host, &document_state, safe_script) + .fetch_metadata + .nonce + .as_deref(), + Some("abc") + ); + + let nonnonceable_script = dom_host.create_element("script"); + assert!(dom_host.set_attribute(nonnonceable_script, "nonce", "abc")); + assert!(dom_host.set_attribute(nonnonceable_script, "data-marker", "value { debug_assert!(load_delay_binding.is_none()); - let nonce = runtime - .dom_host() - .node(node) - .and_then(crate::dom::native::Node::as_element) - .and_then(|element| { - element - .cryptographic_nonce() - .or_else(|| element.attribute("nonce")) - }) + let nonce = crate::host::script_element_nonce_for_csp(runtime.dom_host(), node) .map(str::to_owned); let request = crate::content_security_policy::ContentSecurityPolicyScriptElementRequest { diff --git a/moli-renderer-v8/src/runtime/phase_one/tests/extracted/preload_scanning_and_csp.rs b/moli-renderer-v8/src/runtime/phase_one/tests/extracted/preload_scanning_and_csp.rs index fbeead0e56..5b542cd8e8 100644 --- a/moli-renderer-v8/src/runtime/phase_one/tests/extracted/preload_scanning_and_csp.rs +++ b/moli-renderer-v8/src/runtime/phase_one/tests/extracted/preload_scanning_and_csp.rs @@ -1950,3 +1950,28 @@ fn parser_driver_finish_parser_blocking_pause_resets_insertion_scanner_state() { ); }); } + +#[test] +fn buffered_preload_scanner_clears_nonnonceable_script_nonces() { + let final_url = Url::parse("https://example.test/docs/page.html").expect("test url"); + let requests = collect_preloadable_external_script_requests_from_html( + &final_url, + r#" + + + + + + "#, + ); + + assert_eq!(requests.len(), 5); + assert_eq!( + requests + .iter() + .map(|request| request.fetch_metadata.nonce.as_deref()) + .collect::>(), + vec![Some("abc"), None, None, None, None], + "speculative requests must use the same nonceability gate as parser execution" + ); +} diff --git a/moli-renderer-v8/src/runtime/script_preloads.rs b/moli-renderer-v8/src/runtime/script_preloads.rs index 2e5584e7fc..11d598ffb4 100644 --- a/moli-renderer-v8/src/runtime/script_preloads.rs +++ b/moli-renderer-v8/src/runtime/script_preloads.rs @@ -12,6 +12,7 @@ use html5ever::{ }; use url::Url; +use moli_script::script_element_nonce_is_nonceable; use parking_lot::Mutex; use crate::network::ResourceRequestClient; @@ -1356,13 +1357,23 @@ impl HtmlPreloadScannerSink { if url.scheme() == "data" { return; } + let nonce = html_attr_value(&tag.attrs, "nonce"); + let nonce = script_element_nonce_is_nonceable( + nonce.as_deref(), + tag.had_duplicate_attributes, + tag.attrs + .iter() + .map(|attribute| (attribute.name.local.as_ref(), attribute.value.as_ref())), + ) + .then_some(nonce.as_deref()) + .flatten(); let mut requests = self.requests.borrow_mut(); let fetch_metadata = crate::planning::ScriptFetchMetadata::from_script_attributes( html_attr_value(&tag.attrs, "crossorigin").as_deref(), html_attr_value(&tag.attrs, "referrerpolicy").as_deref(), html_attr_value(&tag.attrs, "charset").as_deref(), html_attr_value(&tag.attrs, "integrity").as_deref(), - html_attr_value(&tag.attrs, "nonce").as_deref(), + nonce, html_attr_value(&tag.attrs, "fetchpriority").as_deref(), ); let Some(key) = BufferedScriptPreloadKey::new(url.clone(), kind_hint, &fetch_metadata) diff --git a/moli-script/src/lib.rs b/moli-script/src/lib.rs index bbfcd130c7..e41bbc218b 100644 --- a/moli-script/src/lib.rs +++ b/moli-script/src/lib.rs @@ -5,12 +5,14 @@ //! classification logic. mod classify; +mod nonce; mod scheduling; pub use classify::{ classify_script_element, classify_script_kind, classify_script_mode, classify_script_preparation, html_script_element_supports_type, }; +pub use nonce::script_element_nonce_is_nonceable; pub use scheduling::{ ScriptElementClassification, ScriptElementClassificationInput, ScriptPreparationClassification, ScriptPreparationClassificationInput, ScriptPreparationDisposition, ScriptSchedulingInput, diff --git a/moli-script/src/nonce.rs b/moli-script/src/nonce.rs new file mode 100644 index 0000000000..defb9d204c --- /dev/null +++ b/moli-script/src/nonce.rs @@ -0,0 +1,69 @@ +/// Return whether a script element's nonce is safe to use for CSP matching. +/// +/// CSP rejects nonces on parser elements with duplicate attributes or with an +/// attribute name/value containing `( + nonce: Option<&str>, + had_duplicate_attributes: bool, + attributes: impl IntoIterator, +) -> bool { + nonce.is_some() + && !had_duplicate_attributes + && attributes.into_iter().all(|(name, value)| { + !contains_nonce_breaking_markup(name) && !contains_nonce_breaking_markup(value) + }) +} + +fn contains_nonce_breaking_markup(value: &str) -> bool { + [ + b"