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]);