Commit 5587c3265c4 for php
commit 5587c3265c4d4dcffceecb61d8b9e621ed7dbac3
Author: David Carlier <devnexen@gmail.com>
Date: Sun Oct 18 10:36:34 2026 +0100
Fix GH-23334: ext/dom: php_dom_free_templated_content() use-after-free
Elements inside template contents had their id attributes registered in
the owning document's ID table, both when parsing a document and when
setting a template's innerHTML. getElementById() and XPath id() could then
reach those nodes, leaving dangling proxies once the template was freed.
The lexbor to libxml2 bridge now tracks whether it is inside template
contents and still marks such attributes as IDs without adding them to the
document's ID table.
Close GH-23341
Co-authored-by: ndossche <7771979+ndossche@users.noreply.github.com>
diff --git a/NEWS b/NEWS
index f5b7b534b64..0c80ffefb1b 100644
--- a/NEWS
+++ b/NEWS
@@ -38,6 +38,8 @@ PHP NEWS
DOMDocument::adoptNode()). (David Carlier)
. Fixed bug GH-24260 (querySelectorAll() returns duplicate elements for
selector lists). (fivefilters)
+ . Fixed bug GH-23334 (Use-after-free in php_dom_free_templated_content()).
+ (David Carlier, ndossche)
- FPM:
. Fixed bug GH-24229 (fcgi_close() passes shutdown() a literal 1, which is
diff --git a/ext/dom/html5_parser.c b/ext/dom/html5_parser.c
index 24f89cb0faf..d946979b705 100644
--- a/ext/dom/html5_parser.c
+++ b/ext/dom/html5_parser.c
@@ -39,6 +39,7 @@ typedef struct work_list_item {
uintptr_t current_active_namespace;
xmlNodePtr lxml_parent;
xmlNsPtr lxml_ns;
+ bool in_template;
} work_list_item;
static void lexbor_libxml2_bridge_work_list_item_push(
@@ -46,7 +47,8 @@ static void lexbor_libxml2_bridge_work_list_item_push(
lxb_dom_node_t *node,
uintptr_t current_active_namespace,
xmlNodePtr lxml_parent,
- xmlNsPtr lxml_ns
+ xmlNsPtr lxml_ns,
+ bool in_template
)
{
work_list_item *item = (work_list_item *) lexbor_array_obj_push_wo_cls(array);
@@ -54,6 +56,7 @@ static void lexbor_libxml2_bridge_work_list_item_push(
item->current_active_namespace = current_active_namespace;
item->lxml_parent = lxml_parent;
item->lxml_ns = lxml_ns;
+ item->in_template = in_template;
}
static unsigned short sanitize_line_nr(size_t line)
@@ -109,6 +112,7 @@ static lexbor_libxml2_bridge_status lexbor_libxml2_bridge_convert(
xmlNodePtr root,
bool compact_text_nodes,
bool create_default_ns,
+ bool in_template_contents,
php_dom_private_data *private_data
)
{
@@ -124,13 +128,14 @@ static lexbor_libxml2_bridge_status lexbor_libxml2_bridge_convert(
lexbor_array_obj_init(&work_list, WORK_LIST_INIT_SIZE, sizeof(work_list_item));
for (lxb_dom_node_t *node = start_node; node != NULL; node = node->prev) {
- lexbor_libxml2_bridge_work_list_item_push(&work_list, node, LXB_NS__UNDEF, root, NULL);
+ lexbor_libxml2_bridge_work_list_item_push(&work_list, node, LXB_NS__UNDEF, root, NULL, in_template_contents);
}
work_list_item *current_stack_item;
while ((current_stack_item = lexbor_array_obj_pop(&work_list)) != NULL) {
lxb_dom_node_t *node = current_stack_item->node;
xmlNodePtr lxml_parent = current_stack_item->lxml_parent;
+ bool in_template = current_stack_item->in_template;
/* CDATA section and processing instructions don't occur in parsed HTML documents.
* The historical types are not emitted by the parser either. */
@@ -173,7 +178,9 @@ static lexbor_libxml2_bridge_status lexbor_libxml2_bridge_convert(
* Other types of nodes contain their children directly. */
xmlNodePtr lxml_child_parent = lxml_element;
lxb_dom_node_t *child_node = element->node.last_child;
+ bool child_in_template = in_template;
if (lxb_html_tree_node_is(&element->node, LXB_TAG_TEMPLATE)) {
+ child_in_template = true;
if (create_default_ns) {
lxml_child_parent = xmlNewDocFragment(lxml_doc);
if (UNEXPECTED(lxml_child_parent == NULL)) {
@@ -198,7 +205,8 @@ static lexbor_libxml2_bridge_status lexbor_libxml2_bridge_convert(
child_node,
entering_namespace,
lxml_child_parent,
- current_lxml_ns
+ current_lxml_ns,
+ child_in_template
);
}
@@ -275,7 +283,7 @@ static lexbor_libxml2_bridge_status lexbor_libxml2_bridge_convert(
/* xmlIsID does some other stuff too that is irrelevant here. */
if (local_name_length == 2 && local_name[0] == 'i' && local_name[1] == 'd' && lxml_attr->ns == NULL) {
- if (xmlAddID(NULL, lxml_doc, value, lxml_attr) == 0) {
+ if (in_template || xmlAddID(NULL, lxml_doc, value, lxml_attr) == 0) {
/* If the ID already exists, the ID attribute still needs to be marked as an ID. */
lxml_attr->atype = XML_ATTRIBUTE_ID;
}
@@ -369,6 +377,7 @@ lexbor_libxml2_bridge_status lexbor_libxml2_bridge_convert_document(
(xmlNodePtr) lxml_doc,
compact_text_nodes,
create_default_ns,
+ false,
private_data
);
if (status != LEXBOR_LIBXML2_BRIDGE_STATUS_OK) {
@@ -385,6 +394,7 @@ lexbor_libxml2_bridge_status lexbor_libxml2_bridge_convert_fragment(
xmlNodePtr *fragment_out,
bool compact_text_nodes,
bool create_default_ns,
+ bool in_template_contents,
php_dom_private_data *private_data
)
{
@@ -398,6 +408,7 @@ lexbor_libxml2_bridge_status lexbor_libxml2_bridge_convert_fragment(
fragment,
compact_text_nodes,
create_default_ns,
+ in_template_contents,
private_data
);
if (status != LEXBOR_LIBXML2_BRIDGE_STATUS_OK) {
diff --git a/ext/dom/html5_parser.h b/ext/dom/html5_parser.h
index 76e60ea8c67..98f47eedf74 100644
--- a/ext/dom/html5_parser.h
+++ b/ext/dom/html5_parser.h
@@ -79,6 +79,7 @@ lexbor_libxml2_bridge_status lexbor_libxml2_bridge_convert_fragment(
xmlNodePtr *fragment_out,
bool compact_text_nodes,
bool create_default_ns,
+ bool in_template_contents,
php_dom_private_data *private_data
);
void lexbor_libxml2_bridge_report_errors(
diff --git a/ext/dom/inner_html_mixin.c b/ext/dom/inner_html_mixin.c
index e81ca5a7b23..ef7f3051dc4 100644
--- a/ext/dom/inner_html_mixin.c
+++ b/ext/dom/inner_html_mixin.c
@@ -181,7 +181,7 @@ static lxb_dom_document_cmode_t dom_translate_quirks_mode(php_libxml_quirks_mode
}
/* https://html.spec.whatwg.org/#html-fragment-parsing-algorithm */
-static xmlNodePtr dom_html_fragment_parsing_algorithm(dom_object *obj, xmlNodePtr context_node, const zend_string *input, php_libxml_quirks_mode quirks_mode)
+static xmlNodePtr dom_html_fragment_parsing_algorithm(dom_object *obj, xmlNodePtr context_node, const zend_string *input, php_libxml_quirks_mode quirks_mode, bool is_template)
{
/* The whole algorithm is implemented in Lexbor, we just have to be the adapter between the
* data structures used in PHP and what Lexbor expects. */
@@ -209,7 +209,7 @@ static xmlNodePtr dom_html_fragment_parsing_algorithm(dom_object *obj, xmlNodePt
xmlNodePtr fragment = NULL;
if (node != NULL) {
/* node->last_child could be NULL, but that is allowed. */
- lexbor_libxml2_bridge_status status = lexbor_libxml2_bridge_convert_fragment(node->last_child, context_node->doc, &fragment, true, true, php_dom_get_private_data(obj));
+ lexbor_libxml2_bridge_status status = lexbor_libxml2_bridge_convert_fragment(node->last_child, context_node->doc, &fragment, true, true, is_template, php_dom_get_private_data(obj));
if (UNEXPECTED(status != LEXBOR_LIBXML2_BRIDGE_STATUS_OK)) {
php_dom_throw_error(INVALID_STATE_ERR, true);
}
@@ -342,18 +342,19 @@ zend_result dom_element_inner_html_write(dom_object *obj, zval *newval)
{
DOM_PROP_NODE(xmlNodePtr, context_node, obj);
+ bool is_template = php_dom_ns_is_fast(context_node, php_dom_ns_is_html_magic_token) && xmlStrEqual(context_node->name, BAD_CAST "template");
xmlNodePtr fragment;
if (context_node->doc->type == XML_DOCUMENT_NODE) {
fragment = dom_xml_fragment_parsing_algorithm(obj, context_node, Z_STR_P(newval));
} else {
- fragment = dom_html_fragment_parsing_algorithm(obj, context_node, Z_STR_P(newval), obj->document->quirks_mode);
+ fragment = dom_html_fragment_parsing_algorithm(obj, context_node, Z_STR_P(newval), obj->document->quirks_mode, is_template);
}
if (fragment == NULL) {
return FAILURE;
}
- if (php_dom_ns_is_fast(context_node, php_dom_ns_is_html_magic_token) && xmlStrEqual(context_node->name, BAD_CAST "template")) {
+ if (is_template) {
context_node = php_dom_ensure_templated_content(php_dom_get_private_data(obj), context_node);
if (context_node == NULL) {
xmlFreeNode(fragment);
diff --git a/ext/dom/tests/modern/html/interactions/gh23334.phpt b/ext/dom/tests/modern/html/interactions/gh23334.phpt
new file mode 100644
index 00000000000..e6425e640fb
--- /dev/null
+++ b/ext/dom/tests/modern/html/interactions/gh23334.phpt
@@ -0,0 +1,26 @@
+--TEST--
+GH-23334 (Template contents must not be reachable through the ID cache)
+--EXTENSIONS--
+dom
+--FILE--
+<?php
+$html = '<!doctype html><body><div id="host"><template id="tpl"><div id="inner">HELLO</div></template></div>';
+
+$doc = Dom\HTMLDocument::createFromString($html, LIBXML_NOERROR);
+var_dump($doc->getElementById('inner'));
+var_dump((new Dom\XPath($doc))->query("id('inner')")->length);
+var_dump($doc->getElementById('tpl')->innerHTML);
+
+$doc = Dom\HTMLDocument::createFromString($html, LIBXML_NOERROR);
+$tpl = $doc->getElementById('tpl');
+$tpl->innerHTML = '<p id="set">WORLD</p>';
+var_dump((new Dom\XPath($doc))->query("id('set')")->length);
+var_dump($tpl->innerHTML);
+$doc->getElementById('host')->innerHTML = '';
+?>
+--EXPECT--
+NULL
+int(0)
+string(27) "<div id="inner">HELLO</div>"
+int(0)
+string(21) "<p id="set">WORLD</p>"