Files
moli/moli-parser/src/script_planning.rs
T
ldm0 a9830ab24b fix(csp): reject nonnonceable script elements
Apply the shared script nonceability check to parser scripts, buffered preloads and runtime script preparation. Suspicious script attribute names and values must not authorize execution merely because a nonce matches. Includes parser, preload and dynamic-preparation regressions.

The attribute checks follow the [CSP3 nonceability algorithm](https://www.w3.org/TR/CSP3/#is-element-nonceable), including case-insensitive `<link`, `<script` and `<style` matches and parser duplicate attributes.

Validation: all branch checks passed. Summary [ 115.690s] 18925 tests run: 18925 passed (9 slow), 16 skipped
2026-09-29 16:27:50 +08:00

786 lines
27 KiB
Rust

use url::Url;
use crate::ParserStreamDocumentSnapshot;
use moli_dom::{
NodeId,
native::{DomHost, NativeDom, NativeNodeId, Node},
};
use moli_fetch::FetchPriorityHint;
use moli_page_types::{ScriptKind, ScriptMode, ScriptSourceKind};
use moli_script::{
ScriptElementClassificationInput, ScriptPreparationClassificationInput,
ScriptPreparationDisposition, classify_script_preparation, script_element_nonce_is_nonceable,
};
pub struct ParserScriptRead {
pub parser_inserted: bool,
pub parser_inserted_for_prepare: bool,
pub already_started: bool,
pub has_nomodule: bool,
pub script_type: Option<String>,
pub script_language: Option<String>,
pub script_event: Option<String>,
pub script_for: Option<String>,
pub script_src: Option<String>,
pub script_text: Option<String>,
pub script_async: bool,
pub async_attribute_present: bool,
pub defer_attribute_present: bool,
pub fetch_metadata: ScriptFetchMetadata,
}
fn parser_script_read_from_node(
node: &Node,
script_src: Option<String>,
script_text: Option<String>,
) -> Option<ParserScriptRead> {
let element = node.as_element()?;
if !element.is_script_element() {
return None;
}
// `type` and `async` belong to both HTMLScriptElement and
// SVGScriptElement. The remaining classification/scheduling attributes
// below are HTML-only; accepting them on SVG scripts would, for example,
// 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(),
already_started: element.script_already_started(),
has_nomodule: is_html_script && element.attribute("nomodule").is_some(),
script_type: element.attribute("type").map(str::to_owned),
script_language: if is_html_script {
element.attribute("language").map(str::to_owned)
} else {
None
},
script_event: if is_html_script {
element.attribute("event").map(str::to_owned)
} else {
None
},
script_for: if is_html_script {
element.attribute("for").map(str::to_owned)
} else {
None
},
script_src,
script_text,
script_async: element.script_async(),
async_attribute_present: element.attribute("async").is_some(),
defer_attribute_present: is_html_script && element.attribute("defer").is_some(),
fetch_metadata: ScriptFetchMetadata::from_script_attributes(
element.attribute("crossorigin"),
element.attribute("referrerpolicy"),
element.attribute("charset"),
element.attribute("integrity"),
nonce,
element.attribute("fetchpriority"),
)
.with_parser_inserted(node.flags().parser_created()),
})
}
pub trait ParserPlanningReadView {
fn parser_script_read(&self, node_id: NativeNodeId) -> Option<ParserScriptRead>;
fn is_connected(&self, node_id: NativeNodeId) -> bool;
fn script_handles(&self) -> Vec<NativeNodeId>;
fn document_order_script_handles(&self) -> Vec<NativeNodeId>;
fn document_order_position(&self, node_id: NativeNodeId) -> Option<usize> {
self.document_order_script_handles()
.into_iter()
.position(|handle| handle == node_id)
}
fn final_url_clone(&self) -> Option<Url>;
fn document_base_url_clone(&self) -> Option<Url>;
fn script_start_line(&self, _node_id: NativeNodeId) -> Option<u64> {
None
}
}
fn collect_script_handles_in_dom_host(
handles: &mut Vec<NativeNodeId>,
host: &DomHost,
current: NativeNodeId,
) {
let Some(node) = host.node(current) else {
return;
};
if node.is_script_element() {
handles.push(current);
}
if let Some(template_contents) = node
.as_element()
.and_then(|element| element.template_contents())
{
collect_script_handles_in_dom_host(handles, host, template_contents);
}
for child in host.child_handles(current) {
collect_script_handles_in_dom_host(handles, host, child);
}
}
impl ParserPlanningReadView for NativeDom {
fn parser_script_read(&self, node_id: NativeNodeId) -> Option<ParserScriptRead> {
let node = self.node(node_id)?;
parser_script_read_from_node(
node,
self.script_src(node_id).map(str::to_owned),
self.script_text(node_id),
)
}
fn document_order_script_handles(&self) -> Vec<NativeNodeId> {
NativeDom::document_order_script_handles(self)
}
fn is_connected(&self, node_id: NativeNodeId) -> bool {
self.node(node_id).is_some_and(Node::is_connected)
}
fn script_handles(&self) -> Vec<NativeNodeId> {
self.script_handles()
}
fn final_url_clone(&self) -> Option<Url> {
self.final_url().cloned()
}
fn document_base_url_clone(&self) -> Option<Url> {
self.document().map(|document| document.base_url().clone())
}
}
impl ParserPlanningReadView for DomHost {
fn parser_script_read(&self, node_id: NativeNodeId) -> Option<ParserScriptRead> {
let node = self.node(node_id)?;
if !node.is_script_element() {
return None;
}
let script_text = self
.dom()
.direct_text_content(node_id)
.and_then(|text| (!text.is_empty()).then_some(text));
parser_script_read_from_node(
node,
node.as_element()
.and_then(|element| element.script_source_attribute())
.map(str::to_owned),
script_text,
)
}
fn document_order_script_handles(&self) -> Vec<NativeNodeId> {
self.script_handles_in_light_subtree(self.document_handle())
}
fn is_connected(&self, node_id: NativeNodeId) -> bool {
DomHost::is_connected(self, node_id)
}
fn script_handles(&self) -> Vec<NativeNodeId> {
let mut handles = Vec::new();
collect_script_handles_in_dom_host(&mut handles, self, self.document_handle());
handles
}
fn final_url_clone(&self) -> Option<Url> {
self.node(self.document_handle())
.and_then(Node::as_document)
.map(|document| document.url().clone())
}
fn document_base_url_clone(&self) -> Option<Url> {
self.document_base_url()
}
}
impl ParserPlanningReadView for ParserStreamDocumentSnapshot {
fn parser_script_read(&self, node_id: NativeNodeId) -> Option<ParserScriptRead> {
let node = self.node(node_id)?;
parser_script_read_from_node(
node,
self.script_src(node_id).map(str::to_owned),
self.script_text(node_id),
)
}
fn document_order_script_handles(&self) -> Vec<NativeNodeId> {
ParserStreamDocumentSnapshot::document_order_script_handles(self)
}
fn is_connected(&self, node_id: NativeNodeId) -> bool {
self.node(node_id).is_some_and(Node::is_connected)
}
fn script_handles(&self) -> Vec<NativeNodeId> {
self.script_handles()
}
fn final_url_clone(&self) -> Option<Url> {
self.final_url().cloned()
}
fn document_base_url_clone(&self) -> Option<Url> {
self.document_base_url()
}
}
#[derive(Debug, Clone)]
pub struct PreparedScript {
pub position: usize,
pub node_id: NodeId,
pub kind: ScriptKind,
pub mode: ScriptMode,
pub source_kind: ScriptSourceKind,
pub fetch_metadata: ScriptFetchMetadata,
pub source: ScriptSource,
pub url: Url,
pub base_url: Url,
pub initiator_url: Url,
pub host_script_handle: Option<String>,
}
#[derive(Debug, Clone)]
pub struct PreparedImportMap {
pub position: usize,
pub node_id: NodeId,
pub source: PreparedImportMapSource,
pub nonce: Option<String>,
pub base_url: Url,
pub initiator_url: Url,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PreparedImportMapSource {
Inline(String),
ExternalUnsupported,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
pub struct ScriptFetchMetadata {
pub cross_origin: Option<String>,
pub referrer_policy: Option<String>,
pub charset: Option<String>,
pub integrity: Option<String>,
pub nonce: Option<String>,
pub fetch_priority: Option<FetchPriorityHint>,
/// HTML's script fetch options capture parser metadata at prepare time.
/// Module descendants and dynamic imports inherit this exact value; it is
/// not equivalent to whether the eventual fetch runs on a parser-owned
/// scheduler lane.
pub parser_inserted: bool,
}
impl ScriptFetchMetadata {
pub fn from_script_attributes(
cross_origin: Option<&str>,
referrer_policy: Option<&str>,
charset: Option<&str>,
integrity: Option<&str>,
nonce: Option<&str>,
fetch_priority: Option<&str>,
) -> Self {
Self {
cross_origin: normalize_cross_origin(cross_origin),
referrer_policy: normalize_referrer_policy(referrer_policy),
charset: normalize_attr_token(charset),
// Attribute presence is significant for module scripts: an explicit
// empty value suppresses integrity metadata supplied by an import map.
integrity: integrity.map(str::trim).map(str::to_owned),
nonce: normalize_non_empty_attr(nonce),
fetch_priority: FetchPriorityHint::from_attribute(fetch_priority),
parser_inserted: false,
}
}
pub fn with_parser_inserted(mut self, parser_inserted: bool) -> Self {
self.parser_inserted = parser_inserted;
self
}
}
fn normalize_cross_origin(value: Option<&str>) -> Option<String> {
let value = value?;
let normalized = value.trim().to_ascii_lowercase();
if normalized == "use-credentials" {
Some("use-credentials".to_owned())
} else {
Some("anonymous".to_owned())
}
}
fn normalize_attr_token(value: Option<&str>) -> Option<String> {
let normalized = value?.trim().to_ascii_lowercase();
(!normalized.is_empty()).then_some(normalized)
}
fn normalize_referrer_policy(value: Option<&str>) -> Option<String> {
let normalized = normalize_attr_token(value)?;
match normalized.as_str() {
"no-referrer"
| "no-referrer-when-downgrade"
| "origin"
| "origin-when-cross-origin"
| "same-origin"
| "strict-origin"
| "strict-origin-when-cross-origin"
| "unsafe-url" => Some(normalized),
_ => None,
}
}
fn normalize_non_empty_attr(value: Option<&str>) -> Option<String> {
let value = value?.trim();
(!value.is_empty()).then(|| value.to_owned())
}
impl PreparedScript {
pub fn with_loaded_source(mut self, source: String) -> Self {
self.source = ScriptSource::Loaded(source);
self
}
pub fn with_loaded_binary_source(mut self, source: String, bytes: Vec<u8>) -> Self {
self.source = ScriptSource::LoadedBinary { source, bytes };
self
}
pub fn waits_for_blocking_stylesheets(&self) -> bool {
match self.kind {
ScriptKind::Classic => {
self.source_kind == ScriptSourceKind::External
&& matches!(
self.mode,
ScriptMode::Normal
| ScriptMode::Defer
| ScriptMode::InOrder
| ScriptMode::ImportMapInOrder
)
}
ScriptKind::Module => matches!(
self.mode,
ScriptMode::ModuleDefer | ScriptMode::ModuleInOrder
),
ScriptKind::ImportMap | ScriptKind::DataBlock => false,
}
}
}
#[derive(Debug, Clone)]
pub enum ScriptSource {
Inline(String),
Loaded(String),
LoadedBinary { source: String, bytes: Vec<u8> },
External,
}
/// Classification result from reading and classifying a parser-visible script.
pub struct ScriptClassification {
pub script: ParserScriptRead,
pub disposition: ScriptPreparationDisposition,
pub source_kind: ScriptSourceKind,
pub legacy_event_for_mismatch: bool,
}
/// Reason a classified script should be skipped rather than prepared.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScriptFilterSkipReason {
AlreadyStarted,
NoModule,
DataBlock,
LegacyEventForMismatch,
}
impl ScriptClassification {
pub fn kind(&self) -> ScriptKind {
self.disposition.kind()
}
pub fn executable(&self) -> Option<(ScriptKind, ScriptMode)> {
self.disposition.executable()
}
pub fn mode(&self) -> Option<ScriptMode> {
self.executable().map(|(_, mode)| mode)
}
/// Returns `None` if this script should be prepared; returns a skip reason otherwise.
/// Centralizes the shared filter: already_started → classic nomodule → DataBlock.
pub fn skip_reason(&self) -> Option<ScriptFilterSkipReason> {
if self.script.already_started {
return Some(ScriptFilterSkipReason::AlreadyStarted);
}
if self.script.has_nomodule && self.kind() == ScriptKind::Classic {
return Some(ScriptFilterSkipReason::NoModule);
}
if self.legacy_event_for_mismatch {
return Some(ScriptFilterSkipReason::LegacyEventForMismatch);
}
match self.kind() {
ScriptKind::DataBlock => Some(ScriptFilterSkipReason::DataBlock),
ScriptKind::Classic | ScriptKind::Module | ScriptKind::ImportMap => None,
}
}
}
/// Reads a script node via `ParserPlanningReadView` and classifies its kind, mode,
/// and source kind. Returns `None` if the node is not a script element.
pub fn classify_parser_script(
document: &impl ParserPlanningReadView,
node_id: NativeNodeId,
) -> Option<ScriptClassification> {
let script = document.parser_script_read(node_id)?;
let source_kind = ScriptSourceKind::from_script_src(&script.script_src);
let classification = classify_script_preparation(ScriptPreparationClassificationInput {
element: ScriptElementClassificationInput {
script_type: script.script_type.as_deref(),
language: script.script_language.as_deref(),
event: script.script_event.as_deref(),
for_attribute: script.script_for.as_deref(),
},
parser_inserted: script.parser_inserted,
allow_parser_blocking_modes: true,
force_async: script.script_async && !script.async_attribute_present,
async_attribute_present: script.async_attribute_present,
defer_attribute_present: script.defer_attribute_present,
source_kind,
});
Some(ScriptClassification {
script,
disposition: classification.disposition,
source_kind,
legacy_event_for_mismatch: classification.legacy_event_for_mismatch,
})
}
/// Outcome of building a `PreparedScript` from a classified script node.
pub enum PrepareScriptOutcome {
/// Script is ready to prepare.
Prepared(Box<PreparedScript>),
/// External script src URL could not be resolved.
UrlResolutionFailed(String),
/// External script src was present but empty, so it is not fetchable.
EmptyExternalSource(String),
/// Inline script had no text content or was empty.
EmptyInlineSource,
/// Import maps and data blocks do not produce executable script payloads.
NonExecutableKind(ScriptKind),
}
/// Builds a `PreparedScript` from an already-classified script node.
///
/// `position` is caller-supplied because different contexts compute document
/// order differently (parser uses `document_order_position`, scheduler uses
/// enumeration index, written-document uses a separate hash map).
pub fn build_prepared_script(
classification: &ScriptClassification,
document_url: Url,
document_base_url: Url,
node_id: NativeNodeId,
position: usize,
) -> PrepareScriptOutcome {
let Some((kind, mode)) = classification.executable() else {
return PrepareScriptOutcome::NonExecutableKind(classification.kind());
};
if let Some(src) = classification.script.script_src.as_deref() {
if src.trim().is_empty() {
return PrepareScriptOutcome::EmptyExternalSource(
"empty script src is not fetchable".to_owned(),
);
}
match document_base_url.join(src) {
Ok(url) => PrepareScriptOutcome::Prepared(Box::new(PreparedScript {
position,
node_id: NodeId::new(node_id.index()),
kind,
mode,
source_kind: classification.source_kind,
fetch_metadata: classification.script.fetch_metadata.clone(),
source: ScriptSource::External,
base_url: url.clone(),
url,
initiator_url: document_url,
host_script_handle: None,
})),
Err(error) => PrepareScriptOutcome::UrlResolutionFailed(format!(
"failed to resolve script src `{src}`: {error}"
)),
}
} else {
match classification.script.script_text.clone() {
Some(source) if !source.is_empty() => {
PrepareScriptOutcome::Prepared(Box::new(PreparedScript {
position,
node_id: NodeId::new(node_id.index()),
kind,
mode,
source_kind: classification.source_kind,
fetch_metadata: classification.script.fetch_metadata.clone(),
source: ScriptSource::Inline(source),
url: document_url.clone(),
base_url: document_base_url,
initiator_url: document_url,
host_script_handle: None,
}))
}
_ => PrepareScriptOutcome::EmptyInlineSource,
}
}
}
pub fn build_prepared_import_map(
classification: &ScriptClassification,
document_url: Url,
document_base_url: Url,
node_id: NativeNodeId,
position: usize,
) -> Option<PreparedImportMap> {
if classification.disposition != ScriptPreparationDisposition::ImportMap {
return None;
}
let source = if classification.script.script_src.is_some() {
PreparedImportMapSource::ExternalUnsupported
} else {
PreparedImportMapSource::Inline(
classification
.script
.script_text
.clone()
.unwrap_or_default(),
)
};
Some(PreparedImportMap {
position,
node_id: NodeId::new(node_id.index()),
source,
nonce: classification.script.fetch_metadata.nonce.clone(),
base_url: document_base_url,
initiator_url: document_url,
})
}
#[cfg(test)]
mod tests {
use super::{
ParserPlanningReadView, PrepareScriptOutcome, ScriptFetchMetadata, ScriptFilterSkipReason,
build_prepared_script, classify_parser_script,
};
use crate::HtmlParser;
use moli_dom::native::DomHost;
use moli_page_types::{ScriptKind, ScriptMode, ScriptSourceKind};
use url::Url;
#[test]
fn script_fetch_metadata_ignores_invalid_referrer_policy() {
let metadata = ScriptFetchMetadata::from_script_attributes(
None,
Some(" always "),
None,
None,
None,
None,
);
assert_eq!(metadata.referrer_policy, None);
}
#[test]
fn script_fetch_metadata_preserves_explicit_empty_integrity() {
let metadata =
ScriptFetchMetadata::from_script_attributes(None, None, None, Some(" "), None, None);
assert_eq!(metadata.integrity.as_deref(), Some(""));
}
#[test]
fn parser_script_classification_uses_language_when_type_is_absent() {
let document = HtmlParser::SCRIPTING_ENABLED.parse(
Url::parse("https://example.test/").unwrap(),
"<!doctype html><script language='javascript'>ok()</script><script language=' javascript '>bad()</script>".to_owned(),
);
let handles = document.script_handles();
let first =
classify_parser_script(&document, handles[0]).expect("first script should classify");
assert_eq!(first.kind(), ScriptKind::Classic);
assert_eq!(first.skip_reason(), None);
let second =
classify_parser_script(&document, handles[1]).expect("second script should classify");
assert_eq!(second.kind(), ScriptKind::DataBlock);
assert!(matches!(
second.skip_reason(),
Some(ScriptFilterSkipReason::DataBlock)
));
}
#[test]
fn parser_script_classification_applies_legacy_for_event_gate() {
let document = HtmlParser::SCRIPTING_ENABLED.parse(
Url::parse("https://example.test/").unwrap(),
"<!doctype html><script for=' window ' event=' onload() '>ok()</script><script for='window' event='onclick'>bad()</script>".to_owned(),
);
let handles = document.script_handles();
let matching =
classify_parser_script(&document, handles[0]).expect("matching script should classify");
assert_eq!(matching.kind(), ScriptKind::Classic);
assert_eq!(matching.skip_reason(), None);
let mismatching = classify_parser_script(&document, handles[1])
.expect("mismatching script should classify");
assert!(matches!(
mismatching.skip_reason(),
Some(ScriptFilterSkipReason::LegacyEventForMismatch)
));
}
#[test]
fn parser_script_classification_applies_nomodule_only_to_classic_scripts() {
let document = HtmlParser::SCRIPTING_ENABLED.parse(
Url::parse("https://example.test/").unwrap(),
"<!doctype html><script nomodule>legacy()</script><script nomodule type='module'>modern()</script>"
.to_owned(),
);
let handles = document.script_handles();
let classic =
classify_parser_script(&document, handles[0]).expect("classic script should classify");
assert_eq!(classic.kind(), ScriptKind::Classic);
assert!(matches!(
classic.skip_reason(),
Some(ScriptFilterSkipReason::NoModule)
));
let module =
classify_parser_script(&document, handles[1]).expect("module script should classify");
assert_eq!(module.kind(), ScriptKind::Module);
assert_eq!(module.skip_reason(), None);
}
#[test]
fn parser_svg_script_ignores_html_only_classification_and_scheduling_attributes() {
let document = HtmlParser::SCRIPTING_ENABLED.parse(
Url::parse("https://example.test/").unwrap(),
"<!doctype html><svg><script nomodule defer language='application/json' for='not-window' event='onclick' href='/svg.js'></script></svg>"
.to_owned(),
);
let handle = document.script_handles()[0];
let classification =
classify_parser_script(&document, handle).expect("SVG script should classify");
assert_eq!(classification.kind(), ScriptKind::Classic);
assert_eq!(classification.mode(), Some(ScriptMode::Normal));
assert_eq!(classification.skip_reason(), None);
assert!(!classification.script.has_nomodule);
assert!(!classification.script.defer_attribute_present);
assert_eq!(classification.script.script_language, None);
assert_eq!(classification.script.script_event, None);
assert_eq!(classification.script.script_for, None);
}
#[test]
fn empty_external_script_src_is_not_resolved_to_document_url() {
let final_url = Url::parse("https://example.test/page.html").unwrap();
let document = HtmlParser::SCRIPTING_ENABLED.parse(
final_url.clone(),
"<!doctype html><script src=\"\"></script>".to_owned(),
);
let handle = document.script_handles()[0];
let classification =
classify_parser_script(&document, handle).expect("script should classify");
assert_eq!(classification.source_kind, ScriptSourceKind::External);
match build_prepared_script(&classification, final_url.clone(), final_url, handle, 0) {
PrepareScriptOutcome::EmptyExternalSource(message) => {
assert!(message.contains("empty script src"));
}
PrepareScriptOutcome::Prepared(script) => {
panic!("empty src must not prepare a fetch for {}", script.url);
}
_ => panic!("empty external src should be reported distinctly"),
}
}
#[test]
fn parser_script_source_uses_direct_text_children_only() {
let document = HtmlParser::SCRIPTING_ENABLED.parse(
Url::parse("https://example.test/").unwrap(),
"<!doctype html><html><head></head><body></body></html>".to_owned(),
);
let mut host = DomHost::from_dom(document.clone());
let script = host.create_element("script");
let span = host.create_element("span");
let descendant_text = host.create_text_node("window.descendant = true;");
let direct_text = host.create_text_node("window.direct = true;");
assert!(host.append_child(span, descendant_text));
assert!(host.append_child(script, span));
assert!(host.append_child(script, direct_text));
let classification = classify_parser_script(&host, script).expect("script should classify");
assert_eq!(
classification.script.script_text.as_deref(),
Some("window.direct = true;")
);
}
#[test]
fn external_script_src_resolves_against_document_base_url() {
let final_url = Url::parse("https://example.test/fetch-src/alpha/base.html").unwrap();
let document = HtmlParser::SCRIPTING_ENABLED.parse(
final_url.clone(),
"<!doctype html><base href=\"../beta/\"><script src=\"test.js\"></script>".to_owned(),
);
let handle = document.script_handles()[0];
let classification =
classify_parser_script(&document, handle).expect("script should classify");
let document_base_url = document
.document_base_url_clone()
.expect("parsed document should expose base URL");
match build_prepared_script(
&classification,
final_url.clone(),
document_base_url,
handle,
0,
) {
PrepareScriptOutcome::Prepared(script) => {
assert_eq!(
script.url.as_str(),
"https://example.test/fetch-src/beta/test.js"
);
assert_eq!(script.initiator_url, final_url);
assert!(
script.fetch_metadata.parser_inserted,
"parser metadata must be captured when the script element is prepared"
);
}
PrepareScriptOutcome::UrlResolutionFailed(error) => {
panic!("script URL should resolve against document base URL: {error}");
}
_ => panic!("external src should prepare a fetch"),
}
}
}