Commit ed34ad446c9 for php.net
commit ed34ad446c99ba0019c402dc4ff121ca37182c54
Author: Sjoerd Langkemper <sjoerd-github@linuxonly.nl>
Date: Sun Aug 2 14:51:00 2026 +0200
ext/standard: limit maximum number of filter chains (#22110)
Limit php://filter URLs to 16 filters by default and allow applications to
override the limit with filter.max_filter_count.
RFC: https://wiki.php.net/rfc/limit-maximum-number-of-filter-chains
Co-authored-by: jvoisin <julien.voisin@dustri.org>
diff --git a/NEWS b/NEWS
index 0d5b6300fc7..425a48481a9 100644
--- a/NEWS
+++ b/NEWS
@@ -21,6 +21,9 @@ PHP NEWS
ReflectionAttribute::getShortName(). (Girgias)
- Standard:
+ . Added the "filter.max_filter_count" stream context option for php://filter
+ URLs. Using more than 16 filters without configuring this option is now
+ deprecated. (Sjoerd Langkemper)
. The following functions now raise a ValueError when the $filename argument
contains NUL bytes: fileperms(), fileinode(), filesize(), fileowner(),
filegroup(), fileatime(), filemtime(), filectime(), filetype(),
diff --git a/UPGRADING b/UPGRADING
index fd66c7e6118..9ac2c2e6f2c 100644
--- a/UPGRADING
+++ b/UPGRADING
@@ -363,6 +363,11 @@ PHP 8.6 UPGRADE NOTES
internal API. It is controlled using error_mode, error_store and
error_handler stream context options.
RFC: https://wiki.php.net/rfc/stream_errors
+ . Added the "filter.max_filter_count" stream context option for php://filter
+ URLs. When set, opening the stream fails with a warning if the URL would
+ add more filters than the configured value. Negative values disable the
+ check.
+ RFC: https://wiki.php.net/rfc/limit-maximum-number-of-filter-chains
. Added stream socket context option so_reuseaddr that allows disabling
address reuse (SO_REUSEADDR) and explicitly uses SO_EXCLUSIVEADDRUSE on
Windows.
@@ -413,6 +418,12 @@ PHP 8.6 UPGRADE NOTES
is no longer maintained.
RFC: https://wiki.php.net/rfc/eol-oniguruma
+- Standard:
+ . Using more than 16 filters in a php://filter URL without configuring the
+ "filter.max_filter_count" stream context option now emits an E_DEPRECATED
+ warning. Use stream_filter_append() or configure this option explicitly.
+ RFC: https://wiki.php.net/rfc/limit-maximum-number-of-filter-chains
+
========================================
5. Changed Functions
========================================
diff --git a/ext/standard/php_fopen_wrapper.c b/ext/standard/php_fopen_wrapper.c
index b18832ebe41..cca9445801f 100644
--- a/ext/standard/php_fopen_wrapper.c
+++ b/ext/standard/php_fopen_wrapper.c
@@ -144,13 +144,38 @@ static const php_stream_ops php_stream_input_ops = {
NULL /* set_option */
};
-static void php_stream_apply_filter_list(php_stream *stream, char *filterlist, int read_chain, int write_chain) /* {{{ */
+static const zend_long max_filter_count_default = 16;
+
+static zend_result php_stream_apply_filter_list(php_stream *stream, char *filterlist, int read_chain, int write_chain, php_stream_context *context) /* {{{ */
{
char *p, *token = NULL;
php_stream_filter *temp_filter;
+ zend_long max_filter_count = max_filter_count_default;
+ bool max_filter_count_configured = false;
+ if (context != NULL) {
+ zval *option_val = php_stream_context_get_option(context, "filter", "max_filter_count");
+ if (option_val) {
+ max_filter_count = zval_get_long(option_val);
+ max_filter_count_configured = true;
+ }
+ }
+
p = php_strtok_r(filterlist, "|", &token);
while (p) {
+ zend_long read_count = read_chain ? stream->readfilters.num_filters : 0;
+ zend_long write_count = write_chain ? stream->writefilters.num_filters : 0;
+
+ if (read_count == max_filter_count || write_count == max_filter_count) {
+ if (max_filter_count_configured) {
+ return FAILURE;
+ } else {
+ // No max_filter_count configured; raise deprecation error if over default
+ zend_error(E_DEPRECATED, "Using more than " ZEND_LONG_FMT " filters in a php://filter URL is deprecated, "
+ "set this limit using the stream context option max_filter_count, or use stream_filter_append", max_filter_count_default);
+ }
+ }
+
php_url_decode(p, strlen(p));
if (read_chain) {
if ((temp_filter = php_stream_filter_create(p, NULL, php_stream_is_persistent(stream)))) {
@@ -172,6 +197,7 @@ static void php_stream_apply_filter_list(php_stream *stream, char *filterlist, i
}
p = php_strtok_r(NULL, "|", &token);
}
+ return SUCCESS;
}
/* }}} */
@@ -362,17 +388,23 @@ static php_stream * php_stream_url_wrap_php(php_stream_wrapper *wrapper, const c
return NULL;
}
+ zend_result safl_result = SUCCESS;
*p = '\0';
p = php_strtok_r(pathdup + 1, "/", &token);
while (p) {
if (!strncasecmp(p, "read=", 5)) {
- php_stream_apply_filter_list(stream, p + 5, 1, 0);
+ safl_result = php_stream_apply_filter_list(stream, p + 5, 1, 0, context);
} else if (!strncasecmp(p, "write=", 6)) {
- php_stream_apply_filter_list(stream, p + 6, 0, 1);
+ safl_result = php_stream_apply_filter_list(stream, p + 6, 0, 1, context);
} else {
- php_stream_apply_filter_list(stream, p, mode_rw & PHP_STREAM_FILTER_READ, mode_rw & PHP_STREAM_FILTER_WRITE);
+ safl_result = php_stream_apply_filter_list(stream, p, mode_rw & PHP_STREAM_FILTER_READ, mode_rw & PHP_STREAM_FILTER_WRITE, context);
}
+
+ if (safl_result == FAILURE) {
+ break;
+ }
+
p = php_strtok_r(NULL, "/", &token);
}
efree(pathdup);
@@ -382,6 +414,13 @@ static php_stream * php_stream_url_wrap_php(php_stream_wrapper *wrapper, const c
return NULL;
}
+ if (safl_result == FAILURE) {
+ php_stream_wrapper_log_warn(wrapper, context, options,
+ PathTooLong, "too many filters");
+ php_stream_close(stream);
+ return NULL;
+ }
+
return stream;
} else {
/* invalid php://thingy */
diff --git a/ext/standard/tests/filters/max_filter_chain.phpt b/ext/standard/tests/filters/max_filter_chain.phpt
new file mode 100644
index 00000000000..b93407eee97
--- /dev/null
+++ b/ext/standard/tests/filters/max_filter_chain.phpt
@@ -0,0 +1,144 @@
+--TEST--
+At most 16 filters can be chained in one stream
+--EXTENSIONS--
+filter
+--FILE--
+<?php
+
+function createFilterChains($n, $resource) {
+ $filter = 'string.toupper';
+ $pipes = 'php://filter/' . implode('|', array_fill(0, $n, $filter)) . "/resource=$resource";
+ $slashes = 'php://filter/' . implode('/', array_fill(0, $n, $filter)) . "/resource=$resource";
+ $resources = str_repeat("php://filter/$filter/resource=", $n) . $resource;
+ return [$pipes, $slashes, $resources];
+}
+
+echo "# file_get_contents on 16 filters\n";
+$allowed_read = createFilterChains(16, 'data:text/plain,sixteen');
+foreach ($allowed_read as $chain) {
+ var_dump(file_get_contents($chain));
+}
+
+echo "# include on 16 filters\n";
+$allowed_include = createFilterChains(16, 'php://temp');
+foreach ($allowed_include as $chain) {
+ var_dump(include $chain);
+}
+
+echo "# file_get_contents on 17 filters\n";
+$blocked_read = createFilterChains(17, 'data:text/plain,seventeen');
+foreach ($blocked_read as $chain) {
+ var_dump(file_get_contents($chain));
+}
+
+echo "# include on 17 filters\n";
+$blocked_include = createFilterChains(17, 'php://temp');
+foreach ($blocked_include as $chain) {
+ var_dump(include $chain);
+}
+
+echo "# file_get_contents on 3 filters, max_filter_count=2\n";
+$ctx = stream_context_create(['filter' => ['max_filter_count' => 2]]);
+$blocked_read = createFilterChains(3, 'data:text/plain,three');
+foreach ($blocked_read as $chain) {
+ var_dump(file_get_contents($chain, false, $ctx));
+}
+
+echo "# file_get_contents on 19 filters, max_filter_count=20\n";
+$ctx = stream_context_create(['filter' => ['max_filter_count' => 20]]);
+$allowed_read = createFilterChains(19, 'data:text/plain,nineteen');
+foreach ($allowed_read as $chain) {
+ var_dump(file_get_contents($chain, false, $ctx));
+}
+
+echo "# warning is only given once, even when we add two filters over the limit\n";
+$blocked_read = createFilterChains(18, 'data:text/plain,eighteen');
+foreach ($blocked_read as $chain) {
+ var_dump(file_get_contents($chain));
+}
+
+echo "# warn on too many write filters, even when number of read filters is OK\n";
+$filter = 'string.toupper';
+$write_filters = implode('|', array_fill(0, 16, $filter));
+$fp = fopen("php://filter/write=$write_filters/$filter/resource=php://temp", 'w+');
+var_dump(is_resource($fp));
+
+echo "# setting max_filter_count to -1 disables warning\n";
+$ctx = stream_context_create(['filter' => ['max_filter_count' => -1]]);
+$allowed_read = createFilterChains(20, 'data:text/plain,twenty');
+foreach ($allowed_read as $chain) {
+ var_dump(file_get_contents($chain, false, $ctx));
+}
+
+echo "# many filters with stream_filter_append still works\n";
+$fp = fopen('data:text/plain,stream_filter_append', 'r');
+for ($i = 0; $i < 80; $i++) {
+ stream_filter_append($fp, 'string.toupper');
+}
+var_dump(fread($fp, 30));
+fclose($fp);
+
+?>
+--EXPECTF--
+# file_get_contents on 16 filters
+string(7) "SIXTEEN"
+string(7) "SIXTEEN"
+string(7) "SIXTEEN"
+# include on 16 filters
+int(1)
+int(1)
+int(1)
+# file_get_contents on 17 filters
+
+Deprecated: Using more than 16 filters in a php://filter URL is deprecated, set this limit using the stream context option max_filter_count, or use stream_filter_append in %smax_filter_chain.php on line %d
+string(9) "SEVENTEEN"
+
+Deprecated: Using more than 16 filters in a php://filter URL is deprecated, set this limit using the stream context option max_filter_count, or use stream_filter_append in %smax_filter_chain.php on line %d
+string(9) "SEVENTEEN"
+
+Deprecated: Using more than 16 filters in a php://filter URL is deprecated, set this limit using the stream context option max_filter_count, or use stream_filter_append in %smax_filter_chain.php on line %d
+string(9) "SEVENTEEN"
+# include on 17 filters
+
+Deprecated: Using more than 16 filters in a php://filter URL is deprecated, set this limit using the stream context option max_filter_count, or use stream_filter_append in %smax_filter_chain.php on line %d
+int(1)
+
+Deprecated: Using more than 16 filters in a php://filter URL is deprecated, set this limit using the stream context option max_filter_count, or use stream_filter_append in %smax_filter_chain.php on line %d
+int(1)
+
+Deprecated: Using more than 16 filters in a php://filter URL is deprecated, set this limit using the stream context option max_filter_count, or use stream_filter_append in %smax_filter_chain.php on line %d
+int(1)
+# file_get_contents on 3 filters, max_filter_count=2
+
+Warning: file_get_contents(): Failed to open stream: too many filters in %s on line %d
+bool(false)
+
+Warning: file_get_contents(): Failed to open stream: too many filters in %s on line %d
+bool(false)
+
+Warning: file_get_contents(): Failed to open stream: too many filters in %s on line %d
+bool(false)
+# file_get_contents on 19 filters, max_filter_count=20
+string(8) "NINETEEN"
+string(8) "NINETEEN"
+string(8) "NINETEEN"
+# warning is only given once, even when we add two filters over the limit
+
+Deprecated: Using more than 16 filters in a php://filter URL is deprecated, set this limit using the stream context option max_filter_count, or use stream_filter_append in %smax_filter_chain.php on line %d
+string(8) "EIGHTEEN"
+
+Deprecated: Using more than 16 filters in a php://filter URL is deprecated, set this limit using the stream context option max_filter_count, or use stream_filter_append in %smax_filter_chain.php on line %d
+string(8) "EIGHTEEN"
+
+Deprecated: Using more than 16 filters in a php://filter URL is deprecated, set this limit using the stream context option max_filter_count, or use stream_filter_append in %smax_filter_chain.php on line %d
+string(8) "EIGHTEEN"
+# warn on too many write filters, even when number of read filters is OK
+
+Deprecated: Using more than 16 filters in a php://filter URL is deprecated, set this limit using the stream context option max_filter_count, or use stream_filter_append in %smax_filter_chain.php on line %d
+bool(true)
+# setting max_filter_count to -1 disables warning
+string(6) "TWENTY"
+string(6) "TWENTY"
+string(6) "TWENTY"
+# many filters with stream_filter_append still works
+string(20) "STREAM_FILTER_APPEND"
diff --git a/main/streams/filter.c b/main/streams/filter.c
index 184e2c96700..1be5cb1b3e5 100644
--- a/main/streams/filter.c
+++ b/main/streams/filter.c
@@ -339,6 +339,7 @@ PHPAPI void php_stream_filter_prepend_ex(php_stream_filter_chain *chain, php_str
chain->tail = filter;
}
chain->head = filter;
+ chain->num_filters += 1;
filter->chain = chain;
}
@@ -359,6 +360,7 @@ PHPAPI zend_result php_stream_filter_append_ex(php_stream_filter_chain *chain, p
chain->head = filter;
}
chain->tail = filter;
+ chain->num_filters += 1;
filter->chain = chain;
if (&(stream->readfilters) == chain && (stream->writepos - stream->readpos) > 0) {
@@ -444,6 +446,7 @@ PHPAPI void php_stream_filter_append(php_stream_filter_chain *chain, php_stream_
filter->prev->next = NULL;
chain->tail = filter->prev;
}
+ chain->num_filters -= 1;
}
}
@@ -545,6 +548,8 @@ PHPAPI php_stream_filter *php_stream_filter_remove(php_stream_filter *filter, bo
filter->chain->tail = filter->prev;
}
+ filter->chain->num_filters -= 1;
+
if (filter->res) {
zend_list_delete(filter->res);
}
diff --git a/main/streams/php_stream_filter_api.h b/main/streams/php_stream_filter_api.h
index b98349d4674..a8ebfc57f8b 100644
--- a/main/streams/php_stream_filter_api.h
+++ b/main/streams/php_stream_filter_api.h
@@ -108,6 +108,7 @@ typedef struct _php_stream_filter_ops {
typedef struct _php_stream_filter_chain {
php_stream_filter *head, *tail;
+ zend_long num_filters;
/* Owning stream */
php_stream *stream;