From ed34ad446c99ba0019c402dc4ff121ca37182c54 Mon Sep 17 00:00:00 2001 From: Sjoerd Langkemper Date: Sun, 2 Aug 2026 14:51:00 +0200 Subject: [PATCH] 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 --- NEWS | 3 + UPGRADING | 11 ++ ext/standard/php_fopen_wrapper.c | 47 +++++- .../tests/filters/max_filter_chain.phpt | 144 ++++++++++++++++++ main/streams/filter.c | 5 + main/streams/php_stream_filter_api.h | 1 + 6 files changed, 207 insertions(+), 4 deletions(-) create mode 100644 ext/standard/tests/filters/max_filter_chain.phpt diff --git a/NEWS b/NEWS index 0d5b6300fc7a..425a48481a98 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 fd66c7e61180..9ac2c2e6f2cb 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 b18832ebe410..cca9445801f0 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 000000000000..b93407eee971 --- /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-- + ['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 184e2c967000..1be5cb1b3e50 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 b98349d46747..a8ebfc57f8bc 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;