fix(csp): reject nonnonceable script elements

This commit is contained in:
ldm0
2026-09-28 01:46:58 +08:00
parent fbfc7ab074
commit ec34e4d5bb
18 changed files with 271 additions and 33 deletions
@@ -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
@@ -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
+12 -1
View File
@@ -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<bool>,
current_script_nonceable: Cell<Option<bool>>,
html4_empty_system_id_quirks_override_pending: Cell<bool>,
}
@@ -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<bool>) -> Option<bool> {
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<Attribute>,
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 {
+2
View File
@@ -79,6 +79,7 @@ struct HtmlTreeSinkState {
captured_blocking_stylesheet_nodes: HashSet<NativeNodeId>,
captured_blocking_stylesheet_signatures: HashSet<DocumentBlockingStylesheetSignature>,
finishing_tree_builder: bool,
non_nonceable_parser_scripts: HashSet<NativeNodeId>,
current_position: ParserSourcePosition,
script_start_positions: HashMap<NativeNodeId, ParserSourcePosition>,
}
@@ -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(),
}
+12 -1
View File
@@ -3210,6 +3210,7 @@ impl ParserStreamHtmlTreeSinkTarget {
name: QualName,
attrs: Vec<Attribute>,
flags: ElementFlags,
script_nonceable: Option<bool>,
) -> 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<bool>,
) -> 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<ParserScriptRead> {
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<NativeNodeId> {
+15 -4
View File
@@ -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()),
+29
View File
@@ -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<ParseHandle> {
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 <link rel=stylesheet charset=utf-8> can also require an
// embedder pause, which must be returned at this tag boundary. Leaving
+36
View File
@@ -1746,6 +1746,42 @@ mod tests {
);
}
#[test]
fn parser_script_handoff_only_exposes_nonceable_nonces() {
fn handoff_nonce(markup: &str) -> Option<String> {
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("<script nonce=abc>safe()</script>"),
Some("abc".to_owned()),
"ordinary parser script nonce should remain usable"
);
for markup in [
"<script attribute<script nonce=abc>blocked()</script>",
"<script data-marker='value<StYlE' nonce=abc>blocked()</script>",
"<script data-marker='value<LiNk' nonce=abc>blocked()</script>",
"<script duplicate duplicate nonce=abc>blocked()</script>",
"<svg><script duplicate duplicate nonce=abc>blocked()</script></svg>",
] {
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(
+1 -1
View File
@@ -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,
)
}
@@ -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,
+1
View File
@@ -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::{
+19 -5
View File
@@ -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 {
@@ -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(),
"<!doctype html><html><head></head><body></body></html>".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<ScRiPt"));
assert_eq!(
RuntimeScriptPreparationContext::capture(&dom_host, &document_state, nonnonceable_script,)
.fetch_metadata
.nonce,
None,
"markup-shaped attributes must make a dynamic script nonce nonnonceable"
);
}
#[test]
fn connected_datablock_type_mutation_alone_does_not_prepare() {
let url = Url::parse("https://example.test/").expect("test url should parse");
@@ -113,15 +113,7 @@ impl JsContextHost {
source,
} => {
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 {
@@ -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#"
<script src="/safe.js" nonce="abc"></script>
<script src="/script.js" nonce="abc" data="value<script"></script>
<script src="/style.js" nonce="abc" data="value<style"></script>
<script src="/link.js" nonce="abc" data="value<link"></script>
<script src="/duplicate.js" nonce="abc" duplicate duplicate></script>
"#,
);
assert_eq!(requests.len(), 5);
assert_eq!(
requests
.iter()
.map(|request| request.fetch_metadata.nonce.as_deref())
.collect::<Vec<_>>(),
vec![Some("abc"), None, None, None, None],
"speculative requests must use the same nonceability gate as parser execution"
);
}
@@ -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)
+2
View File
@@ -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,
+69
View File
@@ -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 `<script`, `<style`, or `<link`, preventing
/// dangling markup from borrowing a trusted nonce.
pub fn script_element_nonce_is_nonceable<'a>(
nonce: Option<&str>,
had_duplicate_attributes: bool,
attributes: impl IntoIterator<Item = (&'a str, &'a str)>,
) -> 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"<script".as_slice(),
b"<style".as_slice(),
b"<link".as_slice(),
]
.into_iter()
.any(|needle| {
value
.as_bytes()
.windows(needle.len())
.any(|window| window.eq_ignore_ascii_case(needle))
})
}
#[cfg(test)]
mod tests {
use super::script_element_nonce_is_nonceable;
#[test]
fn script_nonce_rejects_duplicate_or_markup_shaped_attributes() {
assert!(script_element_nonce_is_nonceable(
Some("abc"),
false,
[("nonce", "abc"), ("data-value", "safe")],
));
assert!(!script_element_nonce_is_nonceable(
None,
false,
[("data-value", "safe")],
));
assert!(!script_element_nonce_is_nonceable(
Some("abc"),
true,
[("nonce", "abc")],
));
for attributes in [
[("attribute<script", "safe"), ("nonce", "abc")],
[("attribute<style", "safe"), ("nonce", "abc")],
[("attribute", "value<ScRiPt"), ("nonce", "abc")],
[("attribute", "value<StYlE"), ("nonce", "abc")],
[("attribute", "value<LiNk"), ("nonce", "abc")],
] {
assert!(!script_element_nonce_is_nonceable(
Some("abc"),
false,
attributes,
));
}
}
}