From a7bb7ddfcb48d98ab4a9b75fdf0b919ea0e12554 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Wed, 2 Sep 2026 01:27:29 +0800 Subject: [PATCH] fix(dom): enforce template content host hierarchy --- moli-dom/src/native/document.rs | 18 +++- moli-dom/src/native/host/clone.rs | 2 + moli-dom/src/native/host/document.rs | 2 + .../native/host/mutation/owner_lifecycle.rs | 7 +- moli-dom/src/native/host/parser.rs | 2 +- moli-dom/src/native/mod.rs | 101 ++++++++++++++++-- moli-dom/src/native/node/data.rs | 7 ++ .../src/native_bridge/node/mutation/core.rs | 27 ++--- .../node/mutation/parent_node.rs | 6 +- .../src/script_vm/tests/dom_xhr/dom.rs | 60 +++++++++++ 10 files changed, 196 insertions(+), 36 deletions(-) diff --git a/moli-dom/src/native/document.rs b/moli-dom/src/native/document.rs index 740343bcd9..50397a169a 100644 --- a/moli-dom/src/native/document.rs +++ b/moli-dom/src/native/document.rs @@ -314,4 +314,20 @@ impl DocumentType { } #[derive(Debug, Clone, Default)] -pub struct DocumentFragment; +pub struct DocumentFragment { + host: Option, +} + +impl DocumentFragment { + pub fn new(host: Option) -> Self { + Self { host } + } + + pub fn host(&self) -> Option { + self.host + } + + pub(crate) fn set_host(&mut self, host: NativeNodeId) { + self.host = Some(host); + } +} diff --git a/moli-dom/src/native/host/clone.rs b/moli-dom/src/native/host/clone.rs index bfd869c380..a0b88c3733 100644 --- a/moli-dom/src/native/host/clone.rs +++ b/moli-dom/src/native/host/clone.rs @@ -252,6 +252,7 @@ impl DomHost { available_to_element_internals, } => { self.dom.register_stylesheet_candidate_tree_scope(clone); + self.dom.set_document_fragment_host(clone, host); self.shadow_roots_by_host.borrow_mut().insert( host, ShadowRootState { @@ -399,6 +400,7 @@ impl DomHost { available_to_element_internals, } => { self.dom.register_stylesheet_candidate_tree_scope(clone); + self.dom.set_document_fragment_host(clone, host); self.shadow_roots_by_host.borrow_mut().insert( host, ShadowRootState { diff --git a/moli-dom/src/native/host/document.rs b/moli-dom/src/native/host/document.rs index 9343479265..0afdee0232 100644 --- a/moli-dom/src/native/host/document.rs +++ b/moli-dom/src/native/host/document.rs @@ -1698,6 +1698,7 @@ impl DomHost { return None; } let root = self.create_document_fragment(); + self.dom.set_document_fragment_host(root, host); let owner_document = self.node(host).and_then(Node::owner_document); let connected = self.is_connected(host); self.dom.register_stylesheet_candidate_tree_scope(root); @@ -2265,6 +2266,7 @@ impl DomHost { continue; } self.dom.register_stylesheet_candidate_tree_scope(root); + self.dom.set_document_fragment_host(root, host); self.shadow_roots_by_host.borrow_mut().insert( host, ShadowRootState { diff --git a/moli-dom/src/native/host/mutation/owner_lifecycle.rs b/moli-dom/src/native/host/mutation/owner_lifecycle.rs index a738d5d650..cc90858a04 100644 --- a/moli-dom/src/native/host/mutation/owner_lifecycle.rs +++ b/moli-dom/src/native/host/mutation/owner_lifecycle.rs @@ -72,7 +72,9 @@ impl NativeDom { mut reference_child: Option, commit_candidate_registration: bool, ) -> Option { - if !self.can_have_children(parent) || parent == child || self.is_ancestor(child, parent) { + if !self.can_have_children(parent) + || self.is_host_including_inclusive_ancestor(child, parent) + { return None; } let parent_type = self.node(parent)?.node_type(); @@ -97,8 +99,7 @@ impl NativeDom { { let fragment_children = self.child_ids(child).collect::>(); let all_children_are_insertable = fragment_children.iter().all(|fragment_child| { - parent != *fragment_child - && !self.is_ancestor(*fragment_child, parent) + !self.is_host_including_inclusive_ancestor(*fragment_child, parent) && self.node(*fragment_child).is_some_and(|node| { !node.data().is_document_fragment() && Self::can_insert_child_type(parent_type, node.node_type()) diff --git a/moli-dom/src/native/host/parser.rs b/moli-dom/src/native/host/parser.rs index a7c38c63c2..679c334fad 100644 --- a/moli-dom/src/native/host/parser.rs +++ b/moli-dom/src/native/host/parser.rs @@ -86,7 +86,7 @@ impl DomHost { if is_template { let template_contents = self .dom - .create_template_contents_fragment_for_document(document_handle); + .create_template_contents_fragment_for_document(document_handle, Some(node_id)); if let Some(element) = self .node_mut(node_id) .and_then(|node| node.data_mut().as_element_mut()) diff --git a/moli-dom/src/native/mod.rs b/moli-dom/src/native/mod.rs index a6e9c884e2..c7063d552e 100644 --- a/moli-dom/src/native/mod.rs +++ b/moli-dom/src/native/mod.rs @@ -353,7 +353,7 @@ impl NativeDom { false, ); if local_name.eq_ignore_ascii_case("template") { - let fragment = self.create_template_contents_fragment(); + let fragment = self.create_template_contents_fragment(handle); if let Some(element) = self .node_mut(handle) .and_then(|node| node.data_mut().as_element_mut()) @@ -392,7 +392,7 @@ impl NativeDom { false, ); if local_name.eq_ignore_ascii_case("template") { - let fragment = self.create_template_contents_fragment(); + let fragment = self.create_template_contents_fragment(handle); if let Some(element) = self .node_mut(handle) .and_then(|node| node.data_mut().as_element_mut()) @@ -496,15 +496,15 @@ impl NativeDom { owner_document: NativeNodeId, ) -> NativeNodeId { self.create_node( - NodeData::DocumentFragment(DocumentFragment), + NodeData::DocumentFragment(DocumentFragment::default()), Some(owner_document), false, false, ) } - pub fn create_template_contents_fragment(&mut self) -> NativeNodeId { - self.create_template_contents_fragment_for_document(self.document_node_id) + pub fn create_template_contents_fragment(&mut self, host: NativeNodeId) -> NativeNodeId { + self.create_template_contents_fragment_for_document(self.document_node_id, Some(host)) } pub fn is_inert_template_document(&self, document_handle: NativeNodeId) -> bool { @@ -514,9 +514,33 @@ impl NativeDom { pub fn create_template_contents_fragment_for_document( &mut self, document_handle: NativeNodeId, + host: Option, ) -> NativeNodeId { let owner_document = self.appropriate_template_contents_owner_document(document_handle); - self.create_document_fragment_for_document(owner_document) + self.create_node( + NodeData::DocumentFragment(DocumentFragment::new(host)), + Some(owner_document), + false, + false, + ) + } + + pub fn set_document_fragment_host( + &mut self, + fragment: NativeNodeId, + host: NativeNodeId, + ) -> bool { + let Some(fragment) = self + .node_mut(fragment) + .and_then(|node| node.data_mut().as_document_fragment_mut()) + else { + return false; + }; + if fragment.host() == Some(host) { + return false; + } + fragment.set_host(host); + true } pub(crate) fn appropriate_template_contents_owner_document( @@ -600,6 +624,28 @@ impl NativeDom { false } + pub fn is_host_including_inclusive_ancestor( + &self, + candidate_ancestor: NativeNodeId, + node_id: NativeNodeId, + ) -> bool { + let mut current = Some(node_id); + while let Some(handle) = current { + if handle == candidate_ancestor { + return true; + } + let Some(node) = self.node(handle) else { + return false; + }; + current = node.parent_node().or_else(|| { + node.data() + .as_document_fragment() + .and_then(DocumentFragment::host) + }); + } + false + } + pub fn detach_from_parent(&mut self, child: NativeNodeId) { let _ = self.detach_from_parent_with_stylesheet_candidate_changes(child); } @@ -2646,7 +2692,8 @@ mod tests { .expect("second template content owner document"); assert_eq!(second_content_owner, content_owner); - let nested_content = dom.create_template_contents_fragment_for_document(content_owner); + let nested_content = + dom.create_template_contents_fragment_for_document(content_owner, None); assert_eq!( dom.node(nested_content).and_then(Node::owner_document), Some(content_owner) @@ -2673,6 +2720,46 @@ mod tests { ); } + #[test] + fn template_content_host_participates_in_hierarchy_checks() { + let mut dom = NativeDom::new_html(test_url()); + let document = dom.document_node_id(); + let parent = dom.create_element("div"); + let template = dom.create_element("template"); + let content = dom + .node(template) + .and_then(Node::as_element) + .and_then(Element::template_contents) + .expect("template content"); + let span = dom.create_element("span"); + + assert!(dom.append_child(document, parent)); + assert!(dom.append_child(parent, template)); + assert!(dom.append_child(content, span)); + assert_eq!( + dom.node(content) + .map(Node::data) + .and_then(NodeData::as_document_fragment) + .and_then(DocumentFragment::host), + Some(template) + ); + assert!(dom.is_host_including_inclusive_ancestor(template, span)); + assert!(dom.is_host_including_inclusive_ancestor(parent, span)); + + for (insertion_parent, child) in [ + (content, parent), + (content, template), + (span, parent), + (span, template), + ] { + assert!(!dom.append_child(insertion_parent, child)); + } + + assert_eq!(dom.parent_node(parent), Some(document)); + assert_eq!(dom.parent_node(template), Some(parent)); + assert_eq!(dom.parent_node(span), Some(content)); + } + #[test] fn cloned_template_content_keeps_template_owner_document() { let mut host = DomHost::from_dom(NativeDom::new_html(test_url())); diff --git a/moli-dom/src/native/node/data.rs b/moli-dom/src/native/node/data.rs index b840ae5b5f..66bd1b841c 100644 --- a/moli-dom/src/native/node/data.rs +++ b/moli-dom/src/native/node/data.rs @@ -146,4 +146,11 @@ impl NodeData { _ => None, } } + + pub fn as_document_fragment_mut(&mut self) -> Option<&mut DocumentFragment> { + match self { + Self::DocumentFragment(fragment) => Some(fragment), + _ => None, + } + } } diff --git a/moli-renderer-v8/src/native_bridge/node/mutation/core.rs b/moli-renderer-v8/src/native_bridge/node/mutation/core.rs index 11d028b3bc..938621a7c5 100644 --- a/moli-renderer-v8/src/native_bridge/node/mutation/core.rs +++ b/moli-renderer-v8/src/native_bridge/node/mutation/core.rs @@ -272,7 +272,7 @@ fn validate_pre_insert_parent_and_ancestor( child: DomHandle, ) -> bool { if !node_can_contain_children(runtime, parent) - || node_move_before_shadow_including_contains(runtime, child, parent) + || node_is_host_including_inclusive_ancestor(runtime, child, parent) { throw_dom_exception(scope, "HierarchyRequestError", 3, "Hierarchy Error"); return false; @@ -415,7 +415,7 @@ fn node_move_before_after_argument_validation( let runtime = unsafe { &*runtime_ptr }; if !node_move_before_is_valid_parent(runtime, parent) || !node_move_before_is_valid_child(runtime, child) - || node_move_before_shadow_including_contains(runtime, child, parent) + || node_is_host_including_inclusive_ancestor(runtime, child, parent) || node_move_before_shadow_including_root(runtime, parent) != node_move_before_shadow_including_root(runtime, child) { @@ -486,29 +486,14 @@ fn node_move_before_is_valid_child(runtime: &JsContextHost, handle: DomHandle) - }) } -pub(super) fn node_move_before_shadow_including_contains( +pub(super) fn node_is_host_including_inclusive_ancestor( runtime: &JsContextHost, ancestor: DomHandle, node: DomHandle, ) -> bool { - let mut current = Some(node); - while let Some(handle) = current { - if handle == ancestor { - return true; - } - current = runtime - .dom_host() - .node(handle) - .and_then(Node::parent_node) - .or_else(|| { - runtime - .dom_host() - .is_shadow_root(handle) - .then(|| runtime.dom_host().shadow_root_host(handle)) - .flatten() - }); - } - false + runtime + .dom_host() + .is_host_including_inclusive_ancestor(ancestor, node) } fn node_move_before_shadow_including_root( diff --git a/moli-renderer-v8/src/native_bridge/node/mutation/parent_node.rs b/moli-renderer-v8/src/native_bridge/node/mutation/parent_node.rs index 40a668aef7..730698dd44 100644 --- a/moli-renderer-v8/src/native_bridge/node/mutation/parent_node.rs +++ b/moli-renderer-v8/src/native_bridge/node/mutation/parent_node.rs @@ -1,5 +1,5 @@ use super::core::{ - node_can_contain_children, node_move_before_shadow_including_contains, node_type_is_insertable, + node_can_contain_children, node_is_host_including_inclusive_ancestor, node_type_is_insertable, pre_insert_validation_handles, }; use super::fragment::{ @@ -148,7 +148,7 @@ fn validate_parent_node_insertion( return false; } for child in inserted { - if node_move_before_shadow_including_contains(runtime, *child, parent) { + if node_is_host_including_inclusive_ancestor(runtime, *child, parent) { throw_dom_exception(scope, "HierarchyRequestError", 3, "Hierarchy Error"); return false; } @@ -256,7 +256,7 @@ fn validate_parent_node_replace_children( return false; } for child in inserted { - if node_move_before_shadow_including_contains(runtime, *child, parent) { + if node_is_host_including_inclusive_ancestor(runtime, *child, parent) { throw_dom_exception(scope, "HierarchyRequestError", 3, "Hierarchy Error"); return false; } diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom.rs index ca68aad964..f74d4a3b28 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom.rs @@ -6737,6 +6737,66 @@ fn detached_templates_share_their_inert_owner_document() { assert_eq!(result, "true|true|true|true|true|true|true|true|true"); } +#[test] +fn template_content_host_rejects_insertion_cycles_after_adoption() { + let mut vm = new_storage_test_vm("https://template-content-host.test/"); + + let result = vm + .eval( + r#" +(() => { + if (!document.documentElement) { + document.appendChild(document.createElement('html')); + } + if (!document.body) { + document.documentElement.appendChild(document.createElement('body')); + } + const parent = document.createElement('div'); + const template = document.createElement('template'); + template.innerHTML = 'content'; + parent.appendChild(template); + document.body.appendChild(parent); + const content = template.content; + const span = content.firstChild; + const errorName = callback => { + try { + callback(); + return 'none'; + } catch (error) { + return error.name; + } + }; + const attempts = () => [ + errorName(() => content.appendChild(parent)), + errorName(() => content.appendChild(template)), + errorName(() => span.appendChild(parent)), + errorName(() => span.appendChild(template)) + ]; + const beforeAdoption = attempts(); + const newDocument = document.implementation.createHTMLDocument(''); + const adopted = newDocument.adoptNode(content); + const afterAdoption = attempts(); + newDocument.body.appendChild(content); + return [ + ...beforeAdoption, + adopted === content, + content.ownerDocument === newDocument, + ...afterAdoption, + content.firstChild === null, + span.parentNode === newDocument.body, + template.parentNode === parent + ].join('|'); +})() +"#, + ) + .expect("template content host hierarchy probe should evaluate"); + + assert_eq!( + result, + "HierarchyRequestError|HierarchyRequestError|HierarchyRequestError|HierarchyRequestError|true|true|HierarchyRequestError|HierarchyRequestError|HierarchyRequestError|HierarchyRequestError|true|true|true" + ); +} + #[test] fn detached_html_image_decode_uses_prototype_method() { let mut vm = new_storage_test_vm("https://detached-image-decode-surface.test/");