Commit 77f2d128494 for php.net

commit 77f2d1284942aa69db806239954875d9b5f510e0
Author: Alexandre Daubois <2144837+alexandre-daubois@users.noreply.github.com>
Date:   Thu Dec 4 09:09:30 2025 +0100

    Fix GH-20370: forbid user stream filters to violate typed property constraints (#20373)

diff --git a/NEWS b/NEWS
index a439bfc2ad6..8cb21eb94fb 100644
--- a/NEWS
+++ b/NEWS
@@ -176,6 +176,8 @@ PHP                                                                        NEWS
 - Streams:
   . Fixed bug GH-19798: XP_SOCKET XP_SSL (Socket stream modules): Incorrect
     condition for Win32/Win64. (Jakub Zelenka)
+  . Fixed bug GH-20370 (User stream filters could violate typed property
+    constraints). (alexandre-daubois)

 - Tidy:
   . Fixed GH-19021 (improved tidyOptGetCategory detection).
diff --git a/ext/standard/tests/filters/gh20370.phpt b/ext/standard/tests/filters/gh20370.phpt
new file mode 100644
index 00000000000..abcf49bfc9f
--- /dev/null
+++ b/ext/standard/tests/filters/gh20370.phpt
@@ -0,0 +1,44 @@
+--TEST--
+GH-20370 (User filters should respect typed properties)
+--FILE--
+<?php
+
+class pass_filter
+{
+    public $filtername;
+    public $params;
+    public int $stream = 1;
+
+    function filter($in, $out, &$consumed, $closing): int
+    {
+        while ($bucket = stream_bucket_make_writeable($in)) {
+            $consumed += $bucket->datalen;
+            stream_bucket_append($out, $bucket);
+        }
+        return PSFS_PASS_ON;
+    }
+}
+
+stream_filter_register("pass", "pass_filter");
+$fp = fopen("php://memory", "w");
+stream_filter_append($fp, "pass");
+
+try {
+    fwrite($fp, "data");
+} catch (TypeError $e) {
+    echo $e::class, ": ", $e->getMessage(), "\n";
+}
+
+try {
+    fclose($fp);
+} catch (TypeError $e) {
+    echo $e::class, ": ", $e->getMessage(), "\n";
+}
+
+unset($fp); // prevent cleanup at shutdown
+
+?>
+--EXPECTF--
+Warning: fwrite(): Unprocessed filter buckets remaining on input brigade in %s on line %d
+TypeError: Cannot assign resource to property pass_filter::$stream of type int
+TypeError: Cannot assign resource to property pass_filter::$stream of type int
diff --git a/ext/standard/tests/filters/gh20370_dynamic_stream_property.phpt b/ext/standard/tests/filters/gh20370_dynamic_stream_property.phpt
new file mode 100644
index 00000000000..97f24b854c5
--- /dev/null
+++ b/ext/standard/tests/filters/gh20370_dynamic_stream_property.phpt
@@ -0,0 +1,51 @@
+--TEST--
+GH-20370 (User filters should update dynamic stream property if it exists)
+--FILE--
+<?php
+
+#[\AllowDynamicProperties]
+class pass_filter
+{
+    public $filtername;
+    public $params;
+
+    function onCreate(): bool
+    {
+        $this->stream = null;
+        return true;
+    }
+
+    function filter($in, $out, &$consumed, $closing): int
+    {
+        while ($bucket = stream_bucket_make_writeable($in)) {
+            $consumed += $bucket->datalen;
+            stream_bucket_append($out, $bucket);
+        }
+        var_dump(property_exists($this, 'stream'));
+        if (is_resource($this->stream)) {
+            var_dump(get_resource_type($this->stream));
+        }
+        return PSFS_PASS_ON;
+    }
+}
+
+stream_filter_register("pass", "pass_filter");
+$fp = fopen("php://memory", "w");
+stream_filter_append($fp, "pass");
+
+fwrite($fp, "data");
+rewind($fp);
+echo fread($fp, 1024) . "\n";
+
+?>
+--EXPECTF--
+bool(true)
+string(6) "stream"
+bool(true)
+string(6) "stream"
+bool(true)
+string(6) "stream"
+bool(true)
+string(6) "stream"
+data
+bool(true)
diff --git a/ext/standard/tests/filters/gh20370_no_stream_property.phpt b/ext/standard/tests/filters/gh20370_no_stream_property.phpt
new file mode 100644
index 00000000000..1b52c4c4155
--- /dev/null
+++ b/ext/standard/tests/filters/gh20370_no_stream_property.phpt
@@ -0,0 +1,37 @@
+--TEST--
+GH-20370 (User filters should not create stream property if not declared)
+--FILE--
+<?php
+
+class pass_filter
+{
+    public $filtername;
+    public $params;
+
+    function filter($in, $out, &$consumed, $closing): int
+    {
+        while ($bucket = stream_bucket_make_writeable($in)) {
+            $consumed += $bucket->datalen;
+            stream_bucket_append($out, $bucket);
+        }
+
+        var_dump(property_exists($this, 'stream'));
+        return PSFS_PASS_ON;
+    }
+}
+
+stream_filter_register("pass", "pass_filter");
+$fp = fopen("php://memory", "w");
+stream_filter_append($fp, "pass");
+fwrite($fp, "data");
+rewind($fp);
+echo fread($fp, 1024) . "\n";
+
+?>
+--EXPECT--
+bool(false)
+bool(false)
+bool(false)
+bool(false)
+data
+bool(false)
diff --git a/ext/standard/tests/filters/gh20370_private_stream_property.phpt b/ext/standard/tests/filters/gh20370_private_stream_property.phpt
new file mode 100644
index 00000000000..bfbbba6099a
--- /dev/null
+++ b/ext/standard/tests/filters/gh20370_private_stream_property.phpt
@@ -0,0 +1,38 @@
+--TEST--
+GH-20370 (User filters should handle private stream property correctly)
+--FILE--
+<?php
+
+class pass_filter
+{
+    public $filtername;
+    public $params;
+    private $stream;
+
+    function filter($in, $out, &$consumed, $closing): int
+    {
+        while ($bucket = stream_bucket_make_writeable($in)) {
+            $consumed += $bucket->datalen;
+            stream_bucket_append($out, $bucket);
+        }
+        return PSFS_PASS_ON;
+    }
+
+    function onClose()
+    {
+        var_dump($this->stream); // should be null
+    }
+}
+
+stream_filter_register("pass", "pass_filter");
+$fp = fopen("php://memory", "w");
+stream_filter_append($fp, "pass", STREAM_FILTER_WRITE);
+
+fwrite($fp, "data");
+rewind($fp);
+echo fread($fp, 1024) . "\n";
+
+?>
+--EXPECT--
+data
+NULL
diff --git a/ext/standard/user_filters.c b/ext/standard/user_filters.c
index 962eaaba7d6..9691b8f95ae 100644
--- a/ext/standard/user_filters.c
+++ b/ext/standard/user_filters.c
@@ -147,14 +147,31 @@ php_stream_filter_status_t userfilter_filter(
 	uint32_t orig_no_fclose = stream->flags & PHP_STREAM_FLAG_NO_FCLOSE;
 	stream->flags |= PHP_STREAM_FLAG_NO_FCLOSE;

-	zval *stream_prop = zend_hash_str_find_ind(Z_OBJPROP_P(obj), "stream", sizeof("stream")-1);
-	if (stream_prop) {
-		/* Give the userfilter class a hook back to the stream */
-		zval_ptr_dtor(stream_prop);
-		php_stream_to_zval(stream, stream_prop);
-		Z_ADDREF_P(stream_prop);
+	/* Give the userfilter class a hook back to the stream */
+	zend_class_entry *old_scope = EG(fake_scope);
+	EG(fake_scope) = Z_OBJCE_P(obj);
+
+	zend_string *stream_name = ZSTR_INIT_LITERAL("stream", 0);
+	bool stream_property_exists = Z_OBJ_HT_P(obj)->has_property(Z_OBJ_P(obj), stream_name, ZEND_PROPERTY_EXISTS, NULL);
+	if (stream_property_exists) {
+		zval stream_zval;
+		php_stream_to_zval(stream, &stream_zval);
+		zend_update_property_ex(Z_OBJCE_P(obj), Z_OBJ_P(obj), stream_name, &stream_zval);
+		/* If property update threw an exception, skip filter execution */
+		if (EG(exception)) {
+			EG(fake_scope) = old_scope;
+			if (buckets_in->head) {
+				php_error_docref(NULL, E_WARNING, "Unprocessed filter buckets remaining on input brigade");
+			}
+			zend_string_release(stream_name);
+			stream->flags &= ~PHP_STREAM_FLAG_NO_FCLOSE;
+			stream->flags |= orig_no_fclose;
+			return PSFS_ERR_FATAL;
+		}
 	}

+	EG(fake_scope) = old_scope;
+
 	ZVAL_STRINGL(&func_name, "filter", sizeof("filter")-1);

 	/* Setup calling arguments */
@@ -195,11 +212,16 @@ php_stream_filter_status_t userfilter_filter(

 	/* filter resources are cleaned up by the stream destructor,
 	 * keeping a reference to the stream resource here would prevent it
-	 * from being destroyed properly */
-	if (stream_prop) {
-		convert_to_null(stream_prop);
+	 * from being destroyed properly.
+	 * Since the property accepted a resource assignment above, it must have
+	 * no type hint or be typed as mixed, so we can safely assign null.
+	 */
+	if (stream_property_exists) {
+		zend_update_property_null(Z_OBJCE_P(obj), Z_OBJ_P(obj), ZSTR_VAL(stream_name), ZSTR_LEN(stream_name));
 	}

+	zend_string_release(stream_name);
+
 	zval_ptr_dtor(&args[3]);
 	zval_ptr_dtor(&args[2]);
 	zval_ptr_dtor(&args[1]);