Commit 30ba0217d1a for php
commit 30ba0217d1a8726b90ce73412d7746089c37a914
Author: Ilia Alshanetsky <ilia@ilia.ws>
Date: Sat Sep 26 18:15:08 2026 -0400
ext/tidy: Reject tidyNode use after the document is reparsed
tidyNode objects kept raw tidy pointers after parseString() replaced the
document tree. Stamp each node with the document parse generation and
reject use when the generations differ.
Closes GH-23937
diff --git a/NEWS b/NEWS
index a011c225aa6..4ffc2b8c0bc 100644
--- a/NEWS
+++ b/NEWS
@@ -166,6 +166,10 @@ PHP NEWS
lookup, and an unchecked CreateFileA() failure. (Ilia Alshanetsky)
. Fix persistent stream context lifetime during shutdown (Levi Morrison)
+- Tidy:
+ . Fixed a use-after-free when a tidyNode is used after its document is
+ reparsed. (Ilia Alshanetsky)
+
- XSL:
. Fixed bug GH-23730 (use-after-free when XSLTProcessor::importStylesheet()
is called during a transformation). (David Carlier)
diff --git a/ext/tidy/tests/reparse_node.phpt b/ext/tidy/tests/reparse_node.phpt
new file mode 100644
index 00000000000..7ba20b3939c
--- /dev/null
+++ b/ext/tidy/tests/reparse_node.phpt
@@ -0,0 +1,66 @@
+--TEST--
+tidyNode objects are invalid after reparsing their document
+--EXTENSIONS--
+tidy
+--FILE--
+<?php
+
+$node = unserialize('O:8:"tidyNode":0:{}');
+try {
+ $node->hasChildren();
+ echo "unowned node: no error\n";
+} catch (Error $e) {
+ echo 'unowned node: ', $e::class, ': ', $e->getMessage(), "\n";
+}
+
+$tidy = tidy_parse_string('<html><body><p>one</p><p>two</p></body></html>');
+$node = $tidy->body()->child[0];
+var_dump($node->isHtml());
+var_dump($node->hasSiblings());
+
+$tidy->parseString('<html><body><p>three</p></body></html>');
+
+$operations = [
+ 'string cast' => static fn() => (string) $node,
+ 'hasChildren' => static fn() => $node->hasChildren(),
+ 'hasSiblings' => static fn() => $node->hasSiblings(),
+ 'isComment' => static fn() => $node->isComment(),
+ 'isHtml' => static fn() => $node->isHtml(),
+ 'isText' => static fn() => $node->isText(),
+ 'isJste' => static fn() => $node->isJste(),
+ 'isAsp' => static fn() => $node->isAsp(),
+ 'isPhp' => static fn() => $node->isPhp(),
+ 'getParent' => static fn() => $node->getParent(),
+ 'getPreviousSibling' => static fn() => $node->getPreviousSibling(),
+ 'getNextSibling' => static fn() => $node->getNextSibling(),
+];
+
+foreach ($operations as $operation => $callback) {
+ try {
+ $callback();
+ echo $operation, ": no error\n";
+ } catch (Error $e) {
+ echo $operation, ': ', $e::class, ': ', $e->getMessage(), "\n";
+ }
+}
+
+var_dump($tidy->body()->child[0]->isHtml());
+
+?>
+--EXPECT--
+unowned node: Error: tidyNode object is not initialized
+bool(true)
+bool(true)
+string cast: Error: tidyNode object is no longer valid after its document was reparsed
+hasChildren: Error: tidyNode object is no longer valid after its document was reparsed
+hasSiblings: Error: tidyNode object is no longer valid after its document was reparsed
+isComment: Error: tidyNode object is no longer valid after its document was reparsed
+isHtml: Error: tidyNode object is no longer valid after its document was reparsed
+isText: Error: tidyNode object is no longer valid after its document was reparsed
+isJste: Error: tidyNode object is no longer valid after its document was reparsed
+isAsp: Error: tidyNode object is no longer valid after its document was reparsed
+isPhp: Error: tidyNode object is no longer valid after its document was reparsed
+getParent: Error: tidyNode object is no longer valid after its document was reparsed
+getPreviousSibling: Error: tidyNode object is no longer valid after its document was reparsed
+getNextSibling: Error: tidyNode object is no longer valid after its document was reparsed
+bool(true)
diff --git a/ext/tidy/tidy.c b/ext/tidy/tidy.c
index b4af4ae811d..4b9d0cb5d1c 100644
--- a/ext/tidy/tidy.c
+++ b/ext/tidy/tidy.c
@@ -76,6 +76,11 @@
} \
obj = Z_TIDY_P(object); \
+#define TIDY_FETCH_VALID_NODE \
+ TIDY_FETCH_ONLY_OBJECT; \
+ if (tidy_node_validate(obj) != SUCCESS) { \
+ RETURN_THROWS(); \
+ }
#define TIDY_SET_DEFAULT_CONFIG(_doc) \
if (TG(default_config) && TG(default_config)[0]) { \
php_tidy_load_config(_doc, TG(default_config)); \
@@ -102,12 +107,14 @@ struct _PHPTidyDoc {
TidyDoc doc;
TidyBuffer *errbuf;
unsigned int ref_count;
+ size_t parse_generation;
unsigned int initialized:1;
};
struct _PHPTidyObj {
TidyNode node;
tidy_obj_type type;
+ size_t node_generation;
PHPTidyDoc *ptdoc;
zend_object std;
};
@@ -302,12 +309,28 @@ static int _php_tidy_set_tidy_opt(TidyDoc doc, const char *optname, zval *value)
return FAILURE;
}
+static zend_result tidy_node_validate(const PHPTidyObj *obj)
+{
+ if (!obj->ptdoc) {
+ zend_throw_error(NULL, "tidyNode object is not initialized");
+ return FAILURE;
+ }
+
+ if (obj->node_generation != obj->ptdoc->parse_generation) {
+ zend_throw_error(NULL, "tidyNode object is no longer valid after its document was reparsed");
+ return FAILURE;
+ }
+
+ return SUCCESS;
+}
+
static void tidy_create_node_object(zval *zv, PHPTidyDoc *ptdoc, TidyNode node)
{
tidy_instantiate(tidy_ce_node, zv);
PHPTidyObj *newobj = Z_TIDY_P(zv);
newobj->node = node;
newobj->type = is_node;
+ newobj->node_generation = ptdoc->parse_generation;
newobj->ptdoc = ptdoc;
newobj->ptdoc->ref_count++;
tidy_add_node_default_properties(newobj);
@@ -465,6 +488,7 @@ static zend_object *tidy_object_new(zend_class_entry *class_type, zend_object_ha
intern->ptdoc = emalloc(sizeof(PHPTidyDoc));
intern->ptdoc->doc = tidyCreate();
intern->ptdoc->ref_count = 1;
+ intern->ptdoc->parse_generation = 0;
intern->ptdoc->initialized = 0;
intern->ptdoc->errbuf = emalloc(sizeof(TidyBuffer));
tidyBufInit(intern->ptdoc->errbuf);
@@ -565,6 +589,9 @@ static zend_result tidy_node_cast_handler(zend_object *in, zval *out, int type)
case IS_STRING:
obj = php_tidy_fetch_object(in);
+ if (tidy_node_validate(obj) != SUCCESS) {
+ return FAILURE;
+ }
tidyBufInit(&buf);
if (obj->ptdoc && tidyNodeGetText(obj->ptdoc->doc, obj->node, &buf)) {
ZVAL_STRINGL(out, (const char *) buf.bp, buf.size-1);
@@ -621,6 +648,10 @@ static void tidy_add_node_default_properties(PHPTidyObj *obj)
zval attribute, children, temp;
const char *name;
+ if (tidy_node_validate(obj) != SUCCESS) {
+ return;
+ }
+
tidyBufInit(&buf);
(void) tidyNodeGetText(obj->ptdoc->doc, obj->node, &buf);
@@ -845,6 +876,7 @@ static int php_tidy_parse_string(PHPTidyObj *obj, const char *string, uint32_t l
obj->ptdoc->initialized = 1;
tidyBufInit(&buf);
+ obj->ptdoc->parse_generation++;
tidyBufAttach(&buf, (byte *) string, len);
if (tidyParseBuffer(obj->ptdoc->doc, &buf) < 0) {
php_error_docref(NULL, E_WARNING, "%s", obj->ptdoc->errbuf->bp);
@@ -1522,7 +1554,7 @@ PHP_FUNCTION(tidy_get_body)
/* {{{ Returns true if this node has children */
PHP_METHOD(tidyNode, hasChildren)
{
- TIDY_FETCH_ONLY_OBJECT;
+ TIDY_FETCH_VALID_NODE;
if (tidyGetChild(obj->node)) {
RETURN_TRUE;
@@ -1535,7 +1567,7 @@ PHP_METHOD(tidyNode, hasChildren)
/* {{{ Returns true if this node has siblings */
PHP_METHOD(tidyNode, hasSiblings)
{
- TIDY_FETCH_ONLY_OBJECT;
+ TIDY_FETCH_VALID_NODE;
if (obj->node && tidyGetNext(obj->node)) {
RETURN_TRUE;
@@ -1548,7 +1580,7 @@ PHP_METHOD(tidyNode, hasSiblings)
/* {{{ Returns true if this node represents a comment */
PHP_METHOD(tidyNode, isComment)
{
- TIDY_FETCH_ONLY_OBJECT;
+ TIDY_FETCH_VALID_NODE;
if (tidyNodeGetType(obj->node) == TidyNode_Comment) {
RETURN_TRUE;
@@ -1561,7 +1593,7 @@ PHP_METHOD(tidyNode, isComment)
/* {{{ Returns true if this node is part of a HTML document */
PHP_METHOD(tidyNode, isHtml)
{
- TIDY_FETCH_ONLY_OBJECT;
+ TIDY_FETCH_VALID_NODE;
switch (tidyNodeGetType(obj->node)) {
case TidyNode_Start:
@@ -1577,7 +1609,7 @@ PHP_METHOD(tidyNode, isHtml)
/* {{{ Returns true if this node represents text (no markup) */
PHP_METHOD(tidyNode, isText)
{
- TIDY_FETCH_ONLY_OBJECT;
+ TIDY_FETCH_VALID_NODE;
if (tidyNodeGetType(obj->node) == TidyNode_Text) {
RETURN_TRUE;
@@ -1590,7 +1622,7 @@ PHP_METHOD(tidyNode, isText)
/* {{{ Returns true if this node is JSTE */
PHP_METHOD(tidyNode, isJste)
{
- TIDY_FETCH_ONLY_OBJECT;
+ TIDY_FETCH_VALID_NODE;
if (tidyNodeGetType(obj->node) == TidyNode_Jste) {
RETURN_TRUE;
@@ -1603,7 +1635,7 @@ PHP_METHOD(tidyNode, isJste)
/* {{{ Returns true if this node is ASP */
PHP_METHOD(tidyNode, isAsp)
{
- TIDY_FETCH_ONLY_OBJECT;
+ TIDY_FETCH_VALID_NODE;
if (tidyNodeGetType(obj->node) == TidyNode_Asp) {
RETURN_TRUE;
@@ -1616,7 +1648,7 @@ PHP_METHOD(tidyNode, isAsp)
/* {{{ Returns true if this node is PHP */
PHP_METHOD(tidyNode, isPhp)
{
- TIDY_FETCH_ONLY_OBJECT;
+ TIDY_FETCH_VALID_NODE;
if (tidyNodeGetType(obj->node) == TidyNode_Php) {
RETURN_TRUE;
@@ -1629,7 +1661,7 @@ PHP_METHOD(tidyNode, isPhp)
/* {{{ Returns the parent node if available or NULL */
PHP_METHOD(tidyNode, getParent)
{
- TIDY_FETCH_ONLY_OBJECT;
+ TIDY_FETCH_VALID_NODE;
TidyNode parent_node = tidyGetParent(obj->node);
if (parent_node) {
@@ -1640,7 +1672,7 @@ PHP_METHOD(tidyNode, getParent)
PHP_METHOD(tidyNode, getPreviousSibling)
{
- TIDY_FETCH_ONLY_OBJECT;
+ TIDY_FETCH_VALID_NODE;
TidyNode previous_node = tidyGetPrev(obj->node);
if (previous_node) {
@@ -1650,7 +1682,7 @@ PHP_METHOD(tidyNode, getPreviousSibling)
PHP_METHOD(tidyNode, getNextSibling)
{
- TIDY_FETCH_ONLY_OBJECT;
+ TIDY_FETCH_VALID_NODE;
TidyNode next_node = tidyGetNext(obj->node);
if (next_node) {