From af57e752e2ad7dd0bc8c1dfc9ba3029c89dfd3fb Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Sat, 18 Apr 2026 12:28:21 -0400 Subject: [PATCH] zlib/bz2: add max_output filter param to cap decompression output Optional max_output parameter on zlib.inflate and bzip2.decompress caps bytes emitted by the filter. When the instance has a cap set and exceeds it, the current bucket is dropped and the filter returns PSFS_ERR_FATAL, stopping decompression amplification mid-stream instead of after the full payload lands on the sink. The parameter is opt-in. Omitting it preserves existing behavior for all current callers. Userland opts in via stream_filter_append($stream, 'zlib.inflate', STREAM_FILTER_WRITE, ['max_output' => N]). --- ext/bz2/bz2_filter.c | 26 ++++++++ .../bz2_filter_decompress_max_output.phpt | 66 +++++++++++++++++++ .../tests/zlib_filter_inflate_max_output.phpt | 66 +++++++++++++++++++ ext/zlib/zlib_filter.c | 31 ++++++++- 4 files changed, 186 insertions(+), 3 deletions(-) create mode 100644 ext/bz2/tests/bz2_filter_decompress_max_output.phpt create mode 100644 ext/zlib/tests/zlib_filter_inflate_max_output.phpt diff --git a/ext/bz2/bz2_filter.c b/ext/bz2/bz2_filter.c index e1b24f6319f2..ea490c0d5967 100644 --- a/ext/bz2/bz2_filter.c +++ b/ext/bz2/bz2_filter.c @@ -33,6 +33,8 @@ typedef struct _php_bz2_filter_data { char *outbuf; size_t inbuf_len; size_t outbuf_len; + size_t max_output; + size_t total_output; enum strm_status status; /* Decompress option */ unsigned int small_footprint : 1; /* Decompress option */ @@ -139,6 +141,12 @@ static php_stream_filter_status_t php_bz2_decompress_filter( if (data->strm.avail_out < data->outbuf_len) { php_stream_bucket *out_bucket; size_t bucketlen = data->outbuf_len - data->strm.avail_out; + data->total_output += bucketlen; + if (data->max_output && data->total_output > data->max_output) { + php_error_docref(NULL, E_NOTICE, "bzip2.decompress: decompressed output exceeded max_output"); + php_stream_bucket_delref(bucket); + return PSFS_ERR_FATAL; + } out_bucket = php_stream_bucket_new(stream, estrndup(data->outbuf, bucketlen), bucketlen, 1, 0); php_stream_bucket_append(buckets_out, out_bucket); data->strm.avail_out = data->outbuf_len; @@ -162,6 +170,11 @@ static php_stream_filter_status_t php_bz2_decompress_filter( if (data->strm.avail_out < data->outbuf_len) { size_t bucketlen = data->outbuf_len - data->strm.avail_out; + data->total_output += bucketlen; + if (data->max_output && data->total_output > data->max_output) { + php_error_docref(NULL, E_NOTICE, "bzip2.decompress: decompressed output exceeded max_output"); + return PSFS_ERR_FATAL; + } bucket = php_stream_bucket_new(stream, estrndup(data->outbuf, bucketlen), bucketlen, 1, 0); php_stream_bucket_append(buckets_out, bucket); data->strm.avail_out = data->outbuf_len; @@ -413,6 +426,19 @@ static php_stream_filter *php_bz2_filter_create(const char *filtername, zval *fi tmpzval = NULL; } + if ((tmpzval = zend_hash_str_find_ind(ht, "max_output", sizeof("max_output")-1))) { + bool failed; + zend_long tmp = zval_try_get_long(tmpzval, &failed); + if (failed) { + php_error_docref(NULL, E_WARNING, "Invalid type for max_output, expected int"); + } else if (tmp <= 0) { + php_error_docref(NULL, E_WARNING, "Invalid parameter given for max_output (" ZEND_LONG_FMT ")", tmp); + } else { + data->max_output = (size_t)tmp; + } + tmpzval = NULL; + } + tmpzval = zend_hash_str_find_ind(ht, "small", sizeof("small")-1); } else { tmpzval = filterparams; diff --git a/ext/bz2/tests/bz2_filter_decompress_max_output.phpt b/ext/bz2/tests/bz2_filter_decompress_max_output.phpt new file mode 100644 index 000000000000..2921ad7b11bf --- /dev/null +++ b/ext/bz2/tests/bz2_filter_decompress_max_output.phpt @@ -0,0 +1,66 @@ +--TEST-- +bzip2.decompress: max_output filter parameter +--EXTENSIONS-- +bz2 +--FILE-- + 2048]); +fwrite($fp, $compressed); +rewind($fp); +var_dump(strlen(stream_get_contents($fp))); +fclose($fp); + +echo "--- max_output below actual size ---\n"; +$fp = fopen('php://temp', 'w+'); +stream_filter_append($fp, 'bzip2.decompress', STREAM_FILTER_WRITE, ['max_output' => 100]); +fwrite($fp, $compressed); +rewind($fp); +var_dump(strlen(stream_get_contents($fp)) <= 100); +fclose($fp); + +echo "--- max_output = 0 (invalid) ---\n"; +$fp = fopen('php://temp', 'w+'); +stream_filter_append($fp, 'bzip2.decompress', STREAM_FILTER_WRITE, ['max_output' => 0]); +fclose($fp); + +echo "--- max_output = -1 (invalid) ---\n"; +$fp = fopen('php://temp', 'w+'); +stream_filter_append($fp, 'bzip2.decompress', STREAM_FILTER_WRITE, ['max_output' => -1]); +fclose($fp); + +echo "--- max_output = array (invalid type) ---\n"; +$fp = fopen('php://temp', 'w+'); +stream_filter_append($fp, 'bzip2.decompress', STREAM_FILTER_WRITE, ['max_output' => []]); +fclose($fp); +?> +--EXPECTF-- +--- unbounded (no max_output) --- +int(1024) +--- max_output above actual size --- +int(1024) +--- max_output below actual size --- + +Notice: fwrite(): bzip2.decompress: decompressed output exceeded max_output in %s on line %d +bool(true) +--- max_output = 0 (invalid) --- + +Warning: stream_filter_append(): Invalid parameter given for max_output (0) in %s on line %d +--- max_output = -1 (invalid) --- + +Warning: stream_filter_append(): Invalid parameter given for max_output (-1) in %s on line %d +--- max_output = array (invalid type) --- + +Warning: stream_filter_append(): Invalid type for max_output, expected int in %s on line %d diff --git a/ext/zlib/tests/zlib_filter_inflate_max_output.phpt b/ext/zlib/tests/zlib_filter_inflate_max_output.phpt new file mode 100644 index 000000000000..4fbf2dc61fc0 --- /dev/null +++ b/ext/zlib/tests/zlib_filter_inflate_max_output.phpt @@ -0,0 +1,66 @@ +--TEST-- +zlib.inflate: max_output filter parameter +--EXTENSIONS-- +zlib +--FILE-- + 2048]); +fwrite($fp, $compressed); +rewind($fp); +var_dump(strlen(stream_get_contents($fp))); +fclose($fp); + +echo "--- max_output below actual size ---\n"; +$fp = fopen('php://temp', 'w+'); +stream_filter_append($fp, 'zlib.inflate', STREAM_FILTER_WRITE, ['max_output' => 100]); +fwrite($fp, $compressed); +rewind($fp); +var_dump(strlen(stream_get_contents($fp)) <= 100); +fclose($fp); + +echo "--- max_output = 0 (invalid) ---\n"; +$fp = fopen('php://temp', 'w+'); +stream_filter_append($fp, 'zlib.inflate', STREAM_FILTER_WRITE, ['max_output' => 0]); +fclose($fp); + +echo "--- max_output = -1 (invalid) ---\n"; +$fp = fopen('php://temp', 'w+'); +stream_filter_append($fp, 'zlib.inflate', STREAM_FILTER_WRITE, ['max_output' => -1]); +fclose($fp); + +echo "--- max_output = array (invalid type) ---\n"; +$fp = fopen('php://temp', 'w+'); +stream_filter_append($fp, 'zlib.inflate', STREAM_FILTER_WRITE, ['max_output' => []]); +fclose($fp); +?> +--EXPECTF-- +--- unbounded (no max_output) --- +int(1024) +--- max_output above actual size --- +int(1024) +--- max_output below actual size --- + +Notice: fwrite(): zlib.inflate: decompressed output exceeded max_output in %s on line %d +bool(true) +--- max_output = 0 (invalid) --- + +Warning: stream_filter_append(): Invalid parameter given for max_output (0) in %s on line %d +--- max_output = -1 (invalid) --- + +Warning: stream_filter_append(): Invalid parameter given for max_output (-1) in %s on line %d +--- max_output = array (invalid type) --- + +Warning: stream_filter_append(): Invalid type for max_output, expected int in %s on line %d diff --git a/ext/zlib/zlib_filter.c b/ext/zlib/zlib_filter.c index b6393feb9083..78ef652ad6c3 100644 --- a/ext/zlib/zlib_filter.c +++ b/ext/zlib/zlib_filter.c @@ -24,6 +24,8 @@ typedef struct _php_zlib_filter_data { size_t inbuf_len; unsigned char *outbuf; size_t outbuf_len; + size_t max_output; + size_t total_output; int persistent; bool finished; /* for zlib.deflate: signals that no flush is pending */ int windowBits; @@ -104,6 +106,12 @@ static php_stream_filter_status_t php_zlib_inflate_filter( if (data->strm.avail_out < data->outbuf_len) { php_stream_bucket *out_bucket; size_t bucketlen = data->outbuf_len - data->strm.avail_out; + data->total_output += bucketlen; + if (data->max_output && data->total_output > data->max_output) { + php_error_docref(NULL, E_NOTICE, "zlib.inflate: decompressed output exceeded max_output"); + php_stream_bucket_delref(bucket); + return PSFS_ERR_FATAL; + } out_bucket = php_stream_bucket_new( stream, estrndup((char *) data->outbuf, bucketlen), bucketlen, 1, 0); php_stream_bucket_append(buckets_out, out_bucket); @@ -125,6 +133,11 @@ static php_stream_filter_status_t php_zlib_inflate_filter( if (data->strm.avail_out < data->outbuf_len) { size_t bucketlen = data->outbuf_len - data->strm.avail_out; + data->total_output += bucketlen; + if (data->max_output && data->total_output > data->max_output) { + php_error_docref(NULL, E_NOTICE, "zlib.inflate: decompressed output exceeded max_output"); + return PSFS_ERR_FATAL; + } bucket = php_stream_bucket_new( stream, estrndup((char *) data->outbuf, bucketlen), bucketlen, 1, 0); php_stream_bucket_append(buckets_out, bucket); @@ -394,11 +407,11 @@ static php_stream_filter *php_zlib_filter_create(const char *filtername, zval *f if (strcasecmp(filtername, "zlib.inflate") == 0) { int windowBits = -MAX_WBITS; - if (filterparams) { + if (filterparams && (Z_TYPE_P(filterparams) == IS_ARRAY || Z_TYPE_P(filterparams) == IS_OBJECT)) { + HashTable *ht = HASH_OF(filterparams); zval *tmpzval; - if ((Z_TYPE_P(filterparams) == IS_ARRAY || Z_TYPE_P(filterparams) == IS_OBJECT) && - (tmpzval = zend_hash_str_find_ind(HASH_OF(filterparams), "window", sizeof("window") - 1))) { + if ((tmpzval = zend_hash_str_find_ind(ht, "window", sizeof("window") - 1))) { /* log-2 base of history window (9 - 15) */ zend_long tmp = zval_get_long(tmpzval); if (tmp < -MAX_WBITS || tmp > MAX_WBITS + 32) { @@ -407,6 +420,18 @@ static php_stream_filter *php_zlib_filter_create(const char *filtername, zval *f windowBits = tmp; } } + + if ((tmpzval = zend_hash_str_find_ind(ht, "max_output", sizeof("max_output") - 1))) { + bool failed; + zend_long tmp = zval_try_get_long(tmpzval, &failed); + if (failed) { + php_error_docref(NULL, E_WARNING, "Invalid type for max_output, expected int"); + } else if (tmp <= 0) { + php_error_docref(NULL, E_WARNING, "Invalid parameter given for max_output (" ZEND_LONG_FMT ")", tmp); + } else { + data->max_output = (size_t)tmp; + } + } } /* Save configuration for reset */