]> git.ipfire.org Git - thirdparty/rspamd.git/commitdiff
[Fix] zstd: share one bounded decompression helper across HTTP, proxy and maps
authorVsevolod Stakhov <vsevolod@rspamd.com>
Thu, 23 Jul 2026 11:55:31 +0000 (12:55 +0100)
committerVsevolod Stakhov <vsevolod@rspamd.com>
Thu, 23 Jul 2026 11:55:31 +0000 (12:55 +0100)
The streaming zstd decompression loop was copy-pasted in seven places
with inconsistent bounding. The proxy had no output ceiling at all: on
request/response bodies (proxy_request_decompress) and on the v3
multipart result and body parts, a zstd bomb from a client or backend
could balloon memory without limit. The task and v3 protocol paths were
bounded but enforced the limit only when the buffer filled, letting the
output overshoot max_message up to twofold, and never validated the
final length. The map shm-cache, file-cache and static paths grew their
buffers with no ceiling.

Add rspamd_zstd_decompress_bounded that validates the frame-advertised
size before any allocation, caps buffer growth strictly at the limit,
drains pending decoder output after the input is consumed and frees the
buffer on every error path. Convert all seven sites to it: max_message
bounds the task, protocol v3 and proxy paths; max_map_size bounds the
map cache and static paths (the remote HTTP map path keeps its per-map
override). Cover the helper with unit tests including advertised-size
and unknown-size zstd bombs.

src/libserver/maps/map.c
src/libserver/protocol.c
src/libserver/task.c
src/libutil/CMakeLists.txt
src/libutil/compression.c [new file with mode: 0644]
src/libutil/compression.h [new file with mode: 0644]
src/rspamd_proxy.c
test/rspamd_cxx_unit.cxx
test/rspamd_cxx_unit_compression.hxx [new file with mode: 0644]

index 24d1dca996cc7fa5f0e343d777d999698e91c0b0..bfac513f56aaca94e512579539bd0b7d75a2d584 100644 (file)
 
 #include <worker_util.h>
 
-#ifdef SYS_ZSTD
-#include "zstd.h"
-#else
-#include "contrib/zstd/zstd.h"
-#endif
+#include "libutil/compression.h"
 
 #undef MAP_DEBUG_REFS
 #ifdef MAP_DEBUG_REFS
@@ -781,7 +777,6 @@ http_map_finish(struct rspamd_http_connection *conn,
                /* Prepare payload: decrypt (if needed) then optionally decompress */
                unsigned char *payload = NULL;
                gsize payload_len = 0;
-               unsigned char *final_out = NULL;
 
                if (cbd->bk->is_encrypted) {
                        if (!rspamd_map_secretbox_decrypt_buf(cbd->bk, in, dlen, &payload, &payload_len)) {
@@ -799,24 +794,19 @@ http_map_finish(struct rspamd_http_connection *conn,
 
                /* If compressed flag is set OR payload looks like zstd, decompress */
                if (cbd->bk->is_compressed || rspamd_map_payload_is_zstd(payload, payload_len)) {
-                       ZSTD_DStream *zstream;
-                       ZSTD_inBuffer zin;
-                       ZSTD_outBuffer zout;
-                       gsize outlen, r;
                        gsize max_size = rspamd_map_effective_max_size(cbd);
+                       GError *derr = NULL;
+                       rspamd_fstring_t *decompressed;
 
-                       zin.pos = 0;
-                       zin.src = payload;
-                       zin.size = payload_len;
+                       decompressed = rspamd_zstd_decompress_bounded(NULL, payload, payload_len,
+                                                                                                                 max_size, &derr);
 
-                       if ((outlen = ZSTD_getDecompressedSize(zin.src, zin.size)) == 0) {
-                               outlen = ZSTD_DStreamOutSize();
-                       }
-                       else if (max_size > 0 && outlen > max_size) {
-                               msg_err_map("%s(%s): declared decompressed size %z exceeds max_map_size %z",
+                       if (decompressed == NULL) {
+                               msg_err_map("%s(%s): cannot decompress data: %s",
                                                        cbd->bk->uri,
                                                        rspamd_inet_address_to_string_pretty(cbd->addr),
-                                                       outlen, max_size);
+                                                       derr ? derr->message : "unknown error");
+                               g_clear_error(&derr);
                                if (cbd->bk->is_encrypted && payload && payload != (unsigned char *) in) {
                                        rspamd_explicit_memzero(payload, payload_len);
                                        g_free(payload);
@@ -825,70 +815,16 @@ http_map_finish(struct rspamd_http_connection *conn,
                                goto err;
                        }
 
-                       zstream = ZSTD_createDStream();
-                       ZSTD_initDStream(zstream);
-
-                       final_out = g_malloc(outlen);
-
-                       zout.dst = final_out;
-                       zout.pos = 0;
-                       zout.size = outlen;
-
-                       while (zin.pos < zin.size) {
-                               r = ZSTD_decompressStream(zstream, &zout, &zin);
-
-                               if (ZSTD_isError(r)) {
-                                       msg_err_map("%s(%s): cannot decompress data: %s",
-                                                               cbd->bk->uri,
-                                                               rspamd_inet_address_to_string_pretty(cbd->addr),
-                                                               ZSTD_getErrorName(r));
-                                       ZSTD_freeDStream(zstream);
-                                       g_free(final_out);
-                                       if (cbd->bk->is_encrypted && payload && payload != (unsigned char *) in) {
-                                               rspamd_explicit_memzero(payload, payload_len);
-                                               g_free(payload);
-                                       }
-                                       MAP_RELEASE(cbd->shmem_data, "shmem_data");
-                                       goto err;
-                               }
-
-                               if (zout.pos == zout.size) {
-                                       /* We need to extend output buffer */
-                                       if (max_size > 0 && zout.size >= max_size) {
-                                               msg_err_map("%s(%s): decompressed data exceeds max_map_size %z",
-                                                                       cbd->bk->uri,
-                                                                       rspamd_inet_address_to_string_pretty(cbd->addr),
-                                                                       max_size);
-                                               ZSTD_freeDStream(zstream);
-                                               g_free(final_out);
-                                               if (cbd->bk->is_encrypted && payload && payload != (unsigned char *) in) {
-                                                       rspamd_explicit_memzero(payload, payload_len);
-                                                       g_free(payload);
-                                               }
-                                               MAP_RELEASE(cbd->shmem_data, "shmem_data");
-                                               goto err;
-                                       }
-
-                                       zout.size = zout.size * 2 + 1.0;
-
-                                       if (max_size > 0 && zout.size > max_size) {
-                                               zout.size = max_size;
-                                       }
-
-                                       final_out = g_realloc(zout.dst, zout.size);
-                                       zout.dst = final_out;
-                               }
-                       }
-
-                       ZSTD_freeDStream(zstream);
                        msg_info_map("%s(%s): read map data %z bytes compressed, "
                                                 "%z uncompressed, next check at %s",
                                                 cbd->bk->uri,
                                                 rspamd_inet_address_to_string_pretty(cbd->addr),
-                                                payload_len, zout.pos, next_check_date);
-                       if (!rspamd_map_save_http_cached_file(map, bk, cbd->data, final_out, zout.pos)) {
+                                                payload_len, decompressed->len, next_check_date);
+                       if (!rspamd_map_save_http_cached_file(map, bk, cbd->data,
+                                                                                                 (const unsigned char *) decompressed->str,
+                                                                                                 decompressed->len)) {
                                msg_err_map("%s: failed to save cache file", bk->uri);
-                               g_free(final_out);
+                               rspamd_fstring_free(decompressed);
                                MAP_RELEASE(cbd->shmem_data, "shmem_data");
                                goto err;
                        }
@@ -903,9 +839,10 @@ http_map_finish(struct rspamd_http_connection *conn,
                                                                   &cbd->periodic->cbdata, TRUE);
                        }
                        else {
-                               map->read_callback(final_out, zout.pos, &cbd->periodic->cbdata, TRUE);
+                               map->read_callback(decompressed->str, decompressed->len,
+                                                                  &cbd->periodic->cbdata, TRUE);
                        }
-                       g_free(final_out);
+                       rspamd_fstring_free(decompressed);
                }
                else {
                        msg_info_map("%s(%s): read map data %z bytes, next check at %s",
@@ -1312,61 +1249,33 @@ read_map_file(struct rspamd_map *map, struct file_map_data *data,
 
                                /* If compressed flag is set OR payload looks like zstd, decompress */
                                if (bk->is_compressed || rspamd_map_payload_is_zstd((const unsigned char *) payload, payload_len)) {
-                                       ZSTD_DStream *zstream;
-                                       ZSTD_inBuffer zin;
-                                       ZSTD_outBuffer zout;
-                                       unsigned char *out;
-                                       gsize outlen, r;
-
-                                       zstream = ZSTD_createDStream();
-                                       ZSTD_initDStream(zstream);
-
-                                       zin.pos = 0;
-                                       zin.src = payload;
-                                       zin.size = payload_len;
-
-                                       if ((outlen = ZSTD_getDecompressedSize(zin.src, zin.size)) == 0) {
-                                               outlen = ZSTD_DStreamOutSize();
-                                       }
-
-                                       out = g_malloc(outlen);
-
-                                       zout.dst = out;
-                                       zout.pos = 0;
-                                       zout.size = outlen;
-
-                                       while (zin.pos < zin.size) {
-                                               r = ZSTD_decompressStream(zstream, &zout, &zin);
-
-                                               if (ZSTD_isError(r)) {
-                                                       msg_err_map("%s: cannot decompress data: %s",
-                                                                               data->filename,
-                                                                               ZSTD_getErrorName(r));
-                                                       ZSTD_freeDStream(zstream);
-                                                       g_free(out);
-                                                       if (dec) {
-                                                               rspamd_explicit_memzero(dec, declen);
-                                                               g_free(dec);
-                                                       }
-                                                       munmap(bytes, len);
-                                                       return FALSE;
-                                               }
-
-                                               if (zout.pos == zout.size) {
-                                                       /* We need to extend output buffer */
-                                                       zout.size = zout.size * 2 + 1;
-                                                       out = g_realloc(zout.dst, zout.size);
-                                                       zout.dst = out;
+                                       GError *derr = NULL;
+                                       rspamd_fstring_t *decompressed;
+
+                                       decompressed = rspamd_zstd_decompress_bounded(NULL, payload, payload_len,
+                                                                                                                                 map->cfg ? map->cfg->max_map_size : 0,
+                                                                                                                                 &derr);
+
+                                       if (decompressed == NULL) {
+                                               msg_err_map("%s: cannot decompress data: %s",
+                                                                       data->filename,
+                                                                       derr ? derr->message : "unknown error");
+                                               g_clear_error(&derr);
+                                               if (dec) {
+                                                       rspamd_explicit_memzero(dec, declen);
+                                                       g_free(dec);
                                                }
+                                               munmap(bytes, len);
+                                               return FALSE;
                                        }
 
-                                       ZSTD_freeDStream(zstream);
                                        msg_info_map("%s: read map data, %z bytes compressed, "
                                                                 "%z uncompressed)",
                                                                 data->filename,
-                                                                payload_len, zout.pos);
-                                       map->read_callback(out, zout.pos, &periodic->cbdata, TRUE);
-                                       g_free(out);
+                                                                payload_len, decompressed->len);
+                                       map->read_callback(decompressed->str, decompressed->len,
+                                                                          &periodic->cbdata, TRUE);
+                                       rspamd_fstring_free(decompressed);
 
                                        if (dec) {
                                                rspamd_explicit_memzero(dec, declen);
@@ -1429,57 +1338,29 @@ read_map_static(struct rspamd_map *map, struct static_map_data *data,
 
        if (len > 0) {
                if (bk->is_compressed) {
-                       ZSTD_DStream *zstream;
-                       ZSTD_inBuffer zin;
-                       ZSTD_outBuffer zout;
-                       unsigned char *out;
-                       gsize outlen, r;
-
-                       zstream = ZSTD_createDStream();
-                       ZSTD_initDStream(zstream);
-
-                       zin.pos = 0;
-                       zin.src = bytes;
-                       zin.size = len;
-
-                       if ((outlen = ZSTD_getDecompressedSize(zin.src, zin.size)) == 0) {
-                               outlen = ZSTD_DStreamOutSize();
-                       }
-
-                       out = g_malloc(outlen);
-
-                       zout.dst = out;
-                       zout.pos = 0;
-                       zout.size = outlen;
+                       GError *derr = NULL;
+                       rspamd_fstring_t *decompressed;
 
-                       while (zin.pos < zin.size) {
-                               r = ZSTD_decompressStream(zstream, &zout, &zin);
+                       decompressed = rspamd_zstd_decompress_bounded(NULL, bytes, len,
+                                                                                                                 map->cfg ? map->cfg->max_map_size : 0,
+                                                                                                                 &derr);
 
-                               if (ZSTD_isError(r)) {
-                                       msg_err_map("%s: cannot decompress data: %s",
-                                                               map->name,
-                                                               ZSTD_getErrorName(r));
-                                       ZSTD_freeDStream(zstream);
-                                       g_free(out);
+                       if (decompressed == NULL) {
+                               msg_err_map("%s: cannot decompress data: %s",
+                                                       map->name,
+                                                       derr ? derr->message : "unknown error");
+                               g_clear_error(&derr);
 
-                                       return FALSE;
-                               }
-
-                               if (zout.pos == zout.size) {
-                                       /* We need to extend output buffer */
-                                       zout.size = zout.size * 2 + 1;
-                                       out = g_realloc(zout.dst, zout.size);
-                                       zout.dst = out;
-                               }
+                               return FALSE;
                        }
 
-                       ZSTD_freeDStream(zstream);
                        msg_info_map("%s: read map data, %z bytes compressed, "
                                                 "%z uncompressed)",
                                                 map->name,
-                                                len, zout.pos);
-                       map->read_callback(out, zout.pos, &periodic->cbdata, TRUE);
-                       g_free(out);
+                                                len, decompressed->len);
+                       map->read_callback(decompressed->str, decompressed->len,
+                                                          &periodic->cbdata, TRUE);
+                       rspamd_fstring_free(decompressed);
                }
                else {
                        msg_info_map("%s: read map data, %z bytes",
@@ -1933,61 +1814,33 @@ rspamd_map_read_cached(struct rspamd_map *map, struct rspamd_map_backend *bk,
 
                /* If compressed flag is set OR payload looks like zstd, decompress */
                if (bk->is_compressed || rspamd_map_payload_is_zstd(payload, payload_len)) {
-                       ZSTD_DStream *zstream;
-                       ZSTD_inBuffer zin;
-                       ZSTD_outBuffer zout;
-                       unsigned char *out;
-                       gsize outlen, r;
-
-                       zstream = ZSTD_createDStream();
-                       ZSTD_initDStream(zstream);
-
-                       zin.pos = 0;
-                       zin.src = payload;
-                       zin.size = payload_len;
-
-                       if ((outlen = ZSTD_getDecompressedSize(zin.src, zin.size)) == 0) {
-                               outlen = ZSTD_DStreamOutSize();
-                       }
-
-                       out = g_malloc(outlen);
-
-                       zout.dst = out;
-                       zout.pos = 0;
-                       zout.size = outlen;
-
-                       while (zin.pos < zin.size) {
-                               r = ZSTD_decompressStream(zstream, &zout, &zin);
-
-                               if (ZSTD_isError(r)) {
-                                       msg_err_map("%s: cannot decompress data: %s",
-                                                               bk->uri,
-                                                               ZSTD_getErrorName(r));
-                                       ZSTD_freeDStream(zstream);
-                                       g_free(out);
-                                       if (dec) {
-                                               rspamd_explicit_memzero(dec, declen);
-                                               g_free(dec);
-                                       }
-                                       munmap(in, mmap_len);
-                                       return FALSE;
-                               }
-
-                               if (zout.pos == zout.size) {
-                                       /* We need to extend output buffer */
-                                       zout.size = zout.size * 2 + 1;
-                                       out = g_realloc(zout.dst, zout.size);
-                                       zout.dst = out;
+                       GError *derr = NULL;
+                       rspamd_fstring_t *decompressed;
+
+                       decompressed = rspamd_zstd_decompress_bounded(NULL, payload, payload_len,
+                                                                                                                 map->cfg ? map->cfg->max_map_size : 0,
+                                                                                                                 &derr);
+
+                       if (decompressed == NULL) {
+                               msg_err_map("%s: cannot decompress data: %s",
+                                                       bk->uri,
+                                                       derr ? derr->message : "unknown error");
+                               g_clear_error(&derr);
+                               if (dec) {
+                                       rspamd_explicit_memzero(dec, declen);
+                                       g_free(dec);
                                }
+                               munmap(in, mmap_len);
+                               return FALSE;
                        }
 
-                       ZSTD_freeDStream(zstream);
                        msg_info_map("%s: read map data cached %z bytes compressed, "
                                                 "%z uncompressed",
                                                 bk->uri,
-                                                payload_len, zout.pos);
-                       map->read_callback(out, zout.pos, &periodic->cbdata, TRUE);
-                       g_free(out);
+                                                payload_len, decompressed->len);
+                       map->read_callback(decompressed->str, decompressed->len,
+                                                          &periodic->cbdata, TRUE);
+                       rspamd_fstring_free(decompressed);
                        if (dec) {
                                rspamd_explicit_memzero(dec, declen);
                                g_free(dec);
index 8a13fe8f253ba5eb70476d7085191d437d79b4f4..a557575743d835b833e536c04705f489c9b59024 100644 (file)
 #include "libmime/content_type.h"
 #include <math.h>
 
-#ifdef SYS_ZSTD
-#include "zstd.h"
-#else
-#include "contrib/zstd/zstd.h"
-#endif
+#include "libutil/compression.h"
 
 INIT_LOG_MODULE(protocol)
 
@@ -3043,10 +3039,8 @@ rspamd_protocol_handle_v3_request(struct rspamd_task *task,
                                                                                         msg_part->content_encoding_len,
                                                                                         "zstd", 4) != -1) {
                        /* Decompress message */
-                       ZSTD_DStream *zstream;
-                       ZSTD_inBuffer zin;
-                       ZSTD_outBuffer zout;
-                       gsize outlen, r;
+                       GError *derr = NULL;
+                       rspamd_fstring_t *decompressed;
 
                        if (!rspamd_libs_reset_decompression(task->cfg->libs_ctx)) {
                                g_set_error(&task->err, rspamd_protocol_quark(), 500,
@@ -3054,61 +3048,34 @@ rspamd_protocol_handle_v3_request(struct rspamd_task *task,
                                return FALSE;
                        }
 
-                       zstream = task->cfg->libs_ctx->in_zstream;
-                       zin.src = msg_part->data;
-                       zin.size = msg_part->data_len;
-                       zin.pos = 0;
-
-                       outlen = ZSTD_getDecompressedSize(msg_part->data, msg_part->data_len);
-                       if (outlen == 0) {
-                               outlen = ZSTD_DStreamOutSize();
-                       }
-                       else if (task->cfg->max_message > 0 && outlen > task->cfg->max_message) {
-                               g_set_error(&task->err, rspamd_protocol_quark(), 413,
-                                                       "decompressed message exceeds max_message limit: %lu > %lu",
-                                                       (unsigned long) outlen, (unsigned long) task->cfg->max_message);
-                               return FALSE;
-                       }
-
-                       unsigned char *out = (unsigned char *) g_malloc(outlen);
-                       zout.dst = out;
-                       zout.pos = 0;
-                       zout.size = outlen;
+                       decompressed = rspamd_zstd_decompress_bounded(task->cfg->libs_ctx->in_zstream,
+                                                                                                                 msg_part->data, msg_part->data_len,
+                                                                                                                 task->cfg->max_message, &derr);
 
-                       while (zin.pos < zin.size) {
-                               r = ZSTD_decompressStream(zstream, &zout, &zin);
+                       if (decompressed == NULL) {
+                               int http_code = 400;
 
-                               if (ZSTD_isError(r)) {
-                                       g_set_error(&task->err, rspamd_protocol_quark(), 400,
-                                                               "message decompression error: %s",
-                                                               ZSTD_getErrorName(r));
-                                       g_free(out);
-                                       return FALSE;
+                               if (derr != NULL && derr->code == RSPAMD_DECOMPRESS_ERROR_TOO_LARGE) {
+                                       http_code = 413;
                                }
 
-                               if (zout.pos == zout.size) {
-                                       if (task->cfg->max_message > 0 &&
-                                               zout.size > task->cfg->max_message) {
-                                               g_set_error(&task->err, rspamd_protocol_quark(), 413,
-                                                                       "decompressed message exceeds max_message limit: %lu > %lu",
-                                                                       (unsigned long) zout.size, (unsigned long) task->cfg->max_message);
-                                               g_free(out);
-                                               return FALSE;
-                                       }
-                                       zout.size = zout.size * 2 + 1;
-                                       out = g_realloc(zout.dst, zout.size);
-                                       zout.dst = out;
-                               }
+                               g_set_error(&task->err, rspamd_protocol_quark(), http_code,
+                                                       "message decompression error: %s",
+                                                       derr ? derr->message : "unknown error");
+                               g_clear_error(&derr);
+                               return FALSE;
                        }
 
-                       rspamd_mempool_add_destructor(task->task_pool, g_free, zout.dst);
-                       task->msg.begin = (const char *) zout.dst;
-                       task->msg.len = zout.pos;
+                       rspamd_mempool_add_destructor(task->task_pool,
+                                                                                 (rspamd_mempool_destruct_t) rspamd_fstring_free,
+                                                                                 decompressed);
+                       task->msg.begin = decompressed->str;
+                       task->msg.len = decompressed->len;
                        task->protocol_flags |= RSPAMD_TASK_PROTOCOL_FLAG_COMPRESSED;
 
                        msg_info_protocol("v3: loaded message from zstd compressed part; "
                                                          "compressed: %ul; uncompressed: %ul",
-                                                         (gulong) zin.size, (gulong) zout.pos);
+                                                         (gulong) msg_part->data_len, (gulong) decompressed->len);
                }
                else {
                        /* Zero-copy: point directly into the multipart buffer */
index a74ba57b6c530b4c551ce653be8e0437acf78bce..96280d46068a847c4e6e6404090923095d190e9c 100644 (file)
 
 #include <math.h>
 
-#ifdef SYS_ZSTD
-#include "zstd.h"
-#else
-#include "contrib/zstd/zstd.h"
-#endif
+#include "libutil/compression.h"
 
 __KHASH_IMPL(rspamd_req_headers_hash, static inline,
                         rspamd_ftok_t *, struct rspamd_request_header_chain *, 1,
@@ -619,11 +615,6 @@ rspamd_task_load_message(struct rspamd_task *task,
                t.len = 4;
 
                if (rspamd_ftok_casecmp(tok, &t) == 0) {
-                       ZSTD_DStream *zstream;
-                       ZSTD_inBuffer zin;
-                       ZSTD_outBuffer zout;
-                       unsigned char *out;
-                       gsize outlen, r;
                        gulong dict_id;
 
                        if (!rspamd_libs_reset_decompression(task->cfg->libs_ctx)) {
@@ -660,67 +651,34 @@ rspamd_task_load_message(struct rspamd_task *task,
                                }
                        }
 
-                       zstream = task->cfg->libs_ctx->in_zstream;
+                       GError *derr = NULL;
+                       gsize compressed_len = task->msg.len;
+                       rspamd_fstring_t *decompressed;
 
-                       zin.pos = 0;
-                       zin.src = task->msg.begin;
-                       zin.size = task->msg.len;
+                       decompressed = rspamd_zstd_decompress_bounded(task->cfg->libs_ctx->in_zstream,
+                                                                                                                 task->msg.begin, task->msg.len,
+                                                                                                                 task->cfg->max_message, &derr);
 
-                       if ((outlen = ZSTD_getDecompressedSize(task->msg.begin, task->msg.len)) == 0) {
-                               outlen = ZSTD_DStreamOutSize();
-                       }
-                       else if (task->cfg->max_message > 0 && outlen > task->cfg->max_message) {
+                       if (decompressed == NULL) {
                                g_set_error(&task->err, rspamd_task_quark(),
                                                        RSPAMD_PROTOCOL_ERROR,
-                                                       "decompressed message exceeds max_message limit: %lu > %lu",
-                                                       (unsigned long) outlen, (unsigned long) task->cfg->max_message);
+                                                       "Decompression error: %s",
+                                                       derr ? derr->message : "unknown error");
+                               g_clear_error(&derr);
 
                                return FALSE;
                        }
 
-                       out = g_malloc(outlen);
-                       zout.dst = out;
-                       zout.pos = 0;
-                       zout.size = outlen;
-
-                       while (zin.pos < zin.size) {
-                               r = ZSTD_decompressStream(zstream, &zout, &zin);
-
-                               if (ZSTD_isError(r)) {
-                                       g_set_error(&task->err, rspamd_task_quark(),
-                                                               RSPAMD_PROTOCOL_ERROR,
-                                                               "Decompression error: %s", ZSTD_getErrorName(r));
-                                       g_free(zout.dst);
-
-                                       return FALSE;
-                               }
-
-                               if (zout.pos == zout.size) {
-                                       /* We need to extend output buffer */
-                                       if (task->cfg->max_message > 0 &&
-                                               zout.size > task->cfg->max_message) {
-                                               g_set_error(&task->err, rspamd_task_quark(),
-                                                                       RSPAMD_PROTOCOL_ERROR,
-                                                                       "decompressed message exceeds max_message limit: %lu > %lu",
-                                                                       (unsigned long) zout.size, (unsigned long) task->cfg->max_message);
-                                               g_free(zout.dst);
-
-                                               return FALSE;
-                                       }
-
-                                       zout.size = zout.size * 2 + 1;
-                                       zout.dst = g_realloc(zout.dst, zout.size);
-                               }
-                       }
-
-                       rspamd_mempool_add_destructor(task->task_pool, g_free, zout.dst);
-                       task->msg.begin = zout.dst;
-                       task->msg.len = zout.pos;
+                       rspamd_mempool_add_destructor(task->task_pool,
+                                                                                 (rspamd_mempool_destruct_t) rspamd_fstring_free,
+                                                                                 decompressed);
+                       task->msg.begin = decompressed->str;
+                       task->msg.len = decompressed->len;
                        task->protocol_flags |= RSPAMD_TASK_PROTOCOL_FLAG_COMPRESSED;
 
                        msg_info_task("loaded message from zstd compressed stream; "
                                                  "compressed: %ul; uncompressed: %ul",
-                                                 (gulong) zin.size, (gulong) zout.pos);
+                                                 (gulong) compressed_len, (gulong) decompressed->len);
                }
                else {
                        g_set_error(&task->err, rspamd_task_quark(), RSPAMD_PROTOCOL_ERROR,
index df6da89f2c1abe1176f22547a99a61f8079d8fae..a11c801a1396e7b0d5ee29da03a688bc49014de5 100644 (file)
@@ -1,6 +1,7 @@
 # Librspamd-util
 SET(LIBRSPAMDUTILSRC
                                ${CMAKE_CURRENT_SOURCE_DIR}/addr.c
+                               ${CMAKE_CURRENT_SOURCE_DIR}/compression.c
                                ${CMAKE_CURRENT_SOURCE_DIR}/libev_helper.c
                                ${CMAKE_CURRENT_SOURCE_DIR}/expression.c
                                ${CMAKE_CURRENT_SOURCE_DIR}/fstring.c
diff --git a/src/libutil/compression.c b/src/libutil/compression.c
new file mode 100644 (file)
index 0000000..8206d38
--- /dev/null
@@ -0,0 +1,148 @@
+/*
+ * Copyright 2026 Vsevolod Stakhov
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+#include "config.h"
+#include "compression.h"
+
+GQuark
+rspamd_decompress_quark(void)
+{
+       return g_quark_from_static_string("rspamd-decompress");
+}
+
+rspamd_fstring_t *
+rspamd_zstd_decompress_bounded(ZSTD_DStream *zstream,
+                                                          const void *in, gsize inlen,
+                                                          gsize max_out, GError **err)
+{
+       ZSTD_DStream *own_stream = NULL;
+       ZSTD_inBuffer zin;
+       ZSTD_outBuffer zout;
+       rspamd_fstring_t *body;
+       gsize outlen, r;
+
+       if (zstream == NULL) {
+               own_stream = ZSTD_createDStream();
+
+               if (own_stream == NULL || ZSTD_isError(ZSTD_initDStream(own_stream))) {
+                       g_set_error(err, rspamd_decompress_quark(),
+                                               RSPAMD_DECOMPRESS_ERROR_INIT,
+                                               "cannot initialize zstd decompressor");
+                       if (own_stream != NULL) {
+                               ZSTD_freeDStream(own_stream);
+                       }
+
+                       return NULL;
+               }
+
+               zstream = own_stream;
+       }
+
+       zin.src = in;
+       zin.size = inlen;
+       zin.pos = 0;
+
+       outlen = ZSTD_getDecompressedSize(in, inlen);
+
+       if (outlen == 0) {
+               outlen = ZSTD_DStreamOutSize();
+
+               if (max_out > 0 && outlen > max_out) {
+                       outlen = max_out;
+               }
+       }
+       else if (max_out > 0 && outlen > max_out) {
+               g_set_error(err, rspamd_decompress_quark(),
+                                       RSPAMD_DECOMPRESS_ERROR_TOO_LARGE,
+                                       "declared decompressed size %" G_GSIZE_FORMAT
+                                       " exceeds limit %" G_GSIZE_FORMAT,
+                                       outlen, max_out);
+               if (own_stream != NULL) {
+                       ZSTD_freeDStream(own_stream);
+               }
+
+               return NULL;
+       }
+
+       body = rspamd_fstring_sized_new(outlen);
+       zout.dst = body->str;
+       zout.pos = 0;
+       zout.size = body->allocated;
+
+       if (max_out > 0 && zout.size > max_out) {
+               zout.size = max_out;
+       }
+
+       for (;;) {
+               r = ZSTD_decompressStream(zstream, &zout, &zin);
+
+               if (ZSTD_isError(r)) {
+                       g_set_error(err, rspamd_decompress_quark(),
+                                               RSPAMD_DECOMPRESS_ERROR_DATA,
+                                               "decompression error: %s", ZSTD_getErrorName(r));
+                       rspamd_fstring_free(body);
+                       if (own_stream != NULL) {
+                               ZSTD_freeDStream(own_stream);
+                       }
+
+                       return NULL;
+               }
+
+               if (zin.pos == zin.size && r == 0) {
+                       /* All input consumed and the last frame is complete */
+                       break;
+               }
+
+               if (zout.pos == zout.size) {
+                       /* Need a larger output buffer */
+                       if (max_out > 0 && zout.size >= max_out) {
+                               g_set_error(err, rspamd_decompress_quark(),
+                                                       RSPAMD_DECOMPRESS_ERROR_TOO_LARGE,
+                                                       "decompressed size exceeds limit %" G_GSIZE_FORMAT,
+                                                       max_out);
+                               rspamd_fstring_free(body);
+                               if (own_stream != NULL) {
+                                       ZSTD_freeDStream(own_stream);
+                               }
+
+                               return NULL;
+                       }
+
+                       body = rspamd_fstring_grow(body, zout.size + 1);
+                       zout.dst = body->str;
+                       zout.size = body->allocated;
+
+                       if (max_out > 0 && zout.size > max_out) {
+                               zout.size = max_out;
+                       }
+               }
+               else if (zin.pos == zin.size) {
+                       /*
+                        * Input is exhausted mid-frame and the decoder has nothing more
+                        * to flush: truncated input; keep the partial output as the
+                        * legacy per-caller loops did
+                        */
+                       break;
+               }
+       }
+
+       body->len = zout.pos;
+
+       if (own_stream != NULL) {
+               ZSTD_freeDStream(own_stream);
+       }
+
+       return body;
+}
diff --git a/src/libutil/compression.h b/src/libutil/compression.h
new file mode 100644 (file)
index 0000000..ae9fa97
--- /dev/null
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2026 Vsevolod Stakhov
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+#ifndef RSPAMD_COMPRESSION_H
+#define RSPAMD_COMPRESSION_H
+
+#include "config.h"
+#include "fstring.h"
+
+#ifdef SYS_ZSTD
+#include "zstd.h"
+#else
+#include "contrib/zstd/zstd.h"
+#endif
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+enum rspamd_decompress_error {
+       RSPAMD_DECOMPRESS_ERROR_INIT = 0,
+       RSPAMD_DECOMPRESS_ERROR_DATA,
+       RSPAMD_DECOMPRESS_ERROR_TOO_LARGE,
+};
+
+GQuark rspamd_decompress_quark(void);
+
+/**
+ * Decompress a zstd payload enforcing a hard ceiling on the output size.
+ *
+ * The frame-advertised decompressed size is validated before any allocation,
+ * the output buffer never grows beyond `max_out` and pending decoder output is
+ * drained after the input is consumed, so the returned length is always within
+ * the limit.
+ *
+ * @param zstream stream to use; its state is not reset by this function.
+ *        Pass NULL to use a temporary stream created and freed internally
+ * @param in compressed input
+ * @param inlen input length
+ * @param max_out maximum allowed decompressed size, 0 means unlimited
+ * @param err error output (enum rspamd_decompress_error codes)
+ * @return decompressed payload to be freed with rspamd_fstring_free,
+ *         or NULL on error
+ */
+rspamd_fstring_t *rspamd_zstd_decompress_bounded(ZSTD_DStream *zstream,
+                                                                                                const void *in, gsize inlen,
+                                                                                                gsize max_out,
+                                                                                                GError **err)
+       G_GNUC_WARN_UNUSED_RESULT;
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* RSPAMD_COMPRESSION_H */
index d24d1bbd6ead7c0fa13aeff2603122d028830347..b8d1c60e7b01ffd4073b9dc65c5e5089eb7e5fe9 100644 (file)
 #include <netinet/tcp.h> /* for TCP_NODELAY */
 #endif
 
-#ifdef SYS_ZSTD
-#include "zstd.h"
-#else
-#include "contrib/zstd/zstd.h"
-#endif
+#include "libutil/compression.h"
 
 /* Rotate keys each minute by default */
 #define DEFAULT_ROTATION_TIME 60.0
@@ -1310,7 +1306,7 @@ proxy_backend_parse_results(struct rspamd_proxy_session *session,
 
                const char *result_data = result_part->data;
                gsize result_len = result_part->data_len;
-               unsigned char *decompressed = NULL;
+               rspamd_fstring_t *decompressed = NULL;
 
                /* Check for per-part zstd compression */
                if (result_part->content_encoding &&
@@ -1318,33 +1314,23 @@ proxy_backend_parse_results(struct rspamd_proxy_session *session,
                        rspamd_substring_search_caseless(result_part->content_encoding,
                                                                                         result_part->content_encoding_len,
                                                                                         "zstd", 4) != -1) {
-                       ZSTD_DStream *zstream = ZSTD_createDStream();
-                       ZSTD_initDStream(zstream);
-                       ZSTD_inBuffer zin = {result_data, result_len, 0};
-                       gsize outlen = ZSTD_getDecompressedSize(result_data, result_len);
-                       if (outlen == 0) outlen = ZSTD_DStreamOutSize();
-                       decompressed = g_malloc(outlen);
-                       ZSTD_outBuffer zout = {decompressed, outlen, 0};
-
-                       while (zin.pos < zin.size) {
-                               gsize r = ZSTD_decompressStream(zstream, &zout, &zin);
-                               if (ZSTD_isError(r)) {
-                                       g_free(decompressed);
-                                       ZSTD_freeDStream(zstream);
-                                       rspamd_multipart_form_free(form);
-                                       msg_err_session("result decompression error: %s",
-                                                                       ZSTD_getErrorName(r));
-                                       return FALSE;
-                               }
-                               if (zout.pos == zout.size) {
-                                       zout.size *= 2;
-                                       decompressed = g_realloc(zout.dst, zout.size);
-                                       zout.dst = decompressed;
-                               }
+                       GError *derr = NULL;
+
+                       decompressed = rspamd_zstd_decompress_bounded(NULL,
+                                                                                                                 result_data, result_len,
+                                                                                                                 session->ctx->cfg->max_message,
+                                                                                                                 &derr);
+
+                       if (decompressed == NULL) {
+                               msg_err_session("result decompression error: %s",
+                                                               derr ? derr->message : "unknown error");
+                               g_clear_error(&derr);
+                               rspamd_multipart_form_free(form);
+                               return FALSE;
                        }
-                       ZSTD_freeDStream(zstream);
-                       result_data = (const char *) zout.dst;
-                       result_len = zout.pos;
+
+                       result_data = decompressed->str;
+                       result_len = decompressed->len;
                }
 
                /* Parse UCL from result part */
@@ -1363,7 +1349,7 @@ proxy_backend_parse_results(struct rspamd_proxy_session *session,
                        ucl_parser_add_chunk(parser, (const unsigned char *) result_data, result_len);
                }
 
-               g_free(decompressed);
+               rspamd_fstring_free(decompressed);
 
                if (ucl_parser_get_error(parser)) {
                        msg_err_session("cannot parse result UCL: %s",
@@ -1390,38 +1376,28 @@ proxy_backend_parse_results(struct rspamd_proxy_session *session,
                                rspamd_substring_search_caseless(body_part_entry->content_encoding,
                                                                                                 body_part_entry->content_encoding_len,
                                                                                                 "zstd", 4) != -1) {
-                               ZSTD_DStream *zstream = ZSTD_createDStream();
-                               ZSTD_initDStream(zstream);
-                               ZSTD_inBuffer zin = {bp_data, bp_len, 0};
-                               gsize outlen = ZSTD_getDecompressedSize(bp_data, bp_len);
-                               if (outlen == 0) outlen = ZSTD_DStreamOutSize();
-                               unsigned char *bp_decompressed = g_malloc(outlen);
-                               ZSTD_outBuffer zout = {bp_decompressed, outlen, 0};
-                               gboolean decompress_ok = TRUE;
-
-                               while (zin.pos < zin.size) {
-                                       gsize r = ZSTD_decompressStream(zstream, &zout, &zin);
-                                       if (ZSTD_isError(r)) {
-                                               msg_warn_session("body decompression error: %s",
-                                                                                ZSTD_getErrorName(r));
-                                               decompress_ok = FALSE;
-                                               break;
-                                       }
-                                       if (zout.pos == zout.size) {
-                                               zout.size *= 2;
-                                               bp_decompressed = g_realloc(zout.dst, zout.size);
-                                               zout.dst = bp_decompressed;
-                                       }
+                               GError *derr = NULL;
+                               rspamd_fstring_t *bp_decompressed;
+
+                               bp_decompressed = rspamd_zstd_decompress_bounded(NULL,
+                                                                                                                                bp_data, bp_len,
+                                                                                                                                session->ctx->cfg->max_message,
+                                                                                                                                &derr);
+
+                               if (bp_decompressed == NULL) {
+                                       msg_warn_session("body decompression error: %s",
+                                                                        derr ? derr->message : "unknown error");
+                                       g_clear_error(&derr);
                                }
-                               ZSTD_freeDStream(zstream);
-
-                               if (decompress_ok) {
+                               else {
                                        /* Copy to pool so it persists */
-                                       conn->body_data = rspamd_mempool_alloc(session->pool, zout.pos);
-                                       memcpy((void *) conn->body_data, zout.dst, zout.pos);
-                                       conn->body_len = zout.pos;
+                                       conn->body_data = rspamd_mempool_alloc(session->pool,
+                                                                                                                  bp_decompressed->len);
+                                       memcpy((void *) conn->body_data, bp_decompressed->str,
+                                                  bp_decompressed->len);
+                                       conn->body_len = bp_decompressed->len;
+                                       rspamd_fstring_free(bp_decompressed);
                                }
-                               g_free(bp_decompressed);
                        }
                        else {
                                /* Uncompressed body — copy to pool */
@@ -1645,60 +1621,31 @@ proxy_request_compress(struct rspamd_http_message *msg)
 }
 
 static void
-proxy_request_decompress(struct rspamd_http_message *msg)
+proxy_request_decompress(struct rspamd_http_message *msg, gsize max_size)
 {
        rspamd_fstring_t *body;
        const char *in;
-       gsize inlen, outlen, r;
-       ZSTD_DStream *zstream;
-       ZSTD_inBuffer zin;
-       ZSTD_outBuffer zout;
+       gsize inlen;
 
        if (rspamd_http_message_find_header(msg, COMPRESSION_HEADER)) {
+               GError *err = NULL;
+
                in = rspamd_http_message_get_body(msg, &inlen);
 
                if (in == NULL || inlen == 0) {
                        return;
                }
 
-               zstream = ZSTD_createDStream();
-               ZSTD_initDStream(zstream);
+               body = rspamd_zstd_decompress_bounded(NULL, in, inlen, max_size, &err);
 
-               zin.pos = 0;
-               zin.src = in;
-               zin.size = inlen;
+               if (body == NULL) {
+                       msg_err("cannot decompress body: %s",
+                                       err ? err->message : "unknown error");
+                       g_clear_error(&err);
 
-               if ((outlen = ZSTD_getDecompressedSize(zin.src, zin.size)) == 0) {
-                       outlen = ZSTD_DStreamOutSize();
-               }
-
-               body = rspamd_fstring_sized_new(outlen);
-               zout.dst = body->str;
-               zout.pos = 0;
-               zout.size = outlen;
-
-               while (zin.pos < zin.size) {
-                       r = ZSTD_decompressStream(zstream, &zout, &zin);
-
-                       if (ZSTD_isError(r)) {
-                               msg_err("Decompression error: %s", ZSTD_getErrorName(r));
-                               ZSTD_freeDStream(zstream);
-                               rspamd_fstring_free(body);
-
-                               return;
-                       }
-
-                       if (zout.pos == zout.size) {
-                               /* We need to extend output buffer */
-                               zout.size = zout.size * 2 + 1;
-                               body = rspamd_fstring_grow(body, zout.size);
-                               zout.size = body->allocated;
-                               zout.dst = body->str;
-                       }
+                       return;
                }
 
-               body->len = zout.pos;
-               ZSTD_freeDStream(zstream);
                rspamd_http_message_set_body_from_fstring_steal(msg, body);
                rspamd_http_message_remove_header(msg, COMPRESSION_HEADER);
                rspamd_http_message_remove_header(msg, CONTENT_ENCODING_HEADER);
@@ -1932,7 +1879,7 @@ proxy_backend_mirror_finish_handler(struct rspamd_http_connection *conn,
 
        session = bk_conn->s;
 
-       proxy_request_decompress(msg);
+       proxy_request_decompress(msg, session->ctx->cfg->max_message);
        orig_ct = rspamd_http_message_find_header(msg, "Content-Type");
 
        if (!proxy_backend_parse_results(session, bk_conn, session->ctx->lua_state,
@@ -2460,7 +2407,7 @@ proxy_backend_master_finish_handler(struct rspamd_http_connection *conn,
 
        session = bk_conn->s;
        rspamd_http_connection_steal_msg(session->master_conn->backend_conn);
-       proxy_request_decompress(msg);
+       proxy_request_decompress(msg, session->ctx->cfg->max_message);
 
        /*
         * These are likely set by an http library, so we will double these headers
index 3a2329f79b84abef8d8e9534f74baacf8cbd3e50..95c1460122040e664a9ae21c24a45cfca79cb1c8 100644 (file)
@@ -44,6 +44,7 @@
 #include "rspamd_cxx_unit_symcache_timeout.hxx"
 #include "rspamd_cxx_unit_text_stats.hxx"
 #include "rspamd_cxx_unit_multipattern.hxx"
+#include "rspamd_cxx_unit_compression.hxx"
 
 static gboolean verbose = false;
 static const GOptionEntry entries[] =
diff --git a/test/rspamd_cxx_unit_compression.hxx b/test/rspamd_cxx_unit_compression.hxx
new file mode 100644 (file)
index 0000000..f7dc871
--- /dev/null
@@ -0,0 +1,197 @@
+/*
+ * Copyright 2026 Vsevolod Stakhov
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef RSPAMD_RSPAMD_CXX_UNIT_COMPRESSION_HXX
+#define RSPAMD_RSPAMD_CXX_UNIT_COMPRESSION_HXX
+
+#define DOCTEST_CONFIG_IMPLEMENTATION_IN_DLL
+#include "doctest/doctest.h"
+
+#include "libutil/compression.h"
+
+#include <string>
+#include <vector>
+
+/*
+ * Tests for the bounded zstd decompression helper shared by the HTTP,
+ * proxy and map code paths. The limit must hold both when the frame header
+ * advertises the decompressed size (single-shot compression) and when it
+ * does not (streaming compression), and the returned length must never
+ * exceed it.
+ */
+
+static std::vector<char>
+unit_zstd_compress_oneshot(const std::string &in)
+{
+       std::vector<char> out(ZSTD_compressBound(in.size()));
+       auto r = ZSTD_compress(out.data(), out.size(), in.data(), in.size(), 1);
+       REQUIRE(!ZSTD_isError(r));
+       out.resize(r);
+
+       return out;
+}
+
+/* Streaming compression produces frames without a content-size header */
+static std::vector<char>
+unit_zstd_compress_stream(const std::string &in)
+{
+       auto *cs = ZSTD_createCStream();
+       REQUIRE(cs != nullptr);
+       ZSTD_initCStream(cs, 1);
+
+       std::vector<char> out(ZSTD_compressBound(in.size()) + 128);
+       ZSTD_outBuffer zout = {out.data(), out.size(), 0};
+       ZSTD_inBuffer zin = {in.data(), in.size(), 0};
+
+       while (zin.pos < zin.size) {
+               auto r = ZSTD_compressStream(cs, &zout, &zin);
+               REQUIRE(!ZSTD_isError(r));
+       }
+
+       auto r = ZSTD_endStream(cs, &zout);
+       REQUIRE(r == 0);
+       ZSTD_freeCStream(cs);
+       out.resize(zout.pos);
+
+       return out;
+}
+
+TEST_SUITE("bounded zstd decompression")
+{
+       TEST_CASE("roundtrip without limit")
+       {
+               std::string plain;
+               for (int i = 0; i < 1000; i++) {
+                       plain += "some compressible payload ";
+               }
+
+               auto compressed = unit_zstd_compress_oneshot(plain);
+               GError *err = nullptr;
+               auto *body = rspamd_zstd_decompress_bounded(nullptr, compressed.data(),
+                                                                                                       compressed.size(), 0, &err);
+               REQUIRE(body != nullptr);
+               REQUIRE(err == nullptr);
+               CHECK(std::string{body->str, body->len} == plain);
+               rspamd_fstring_free(body);
+       }
+
+       TEST_CASE("limit exactly equal to decompressed size is allowed")
+       {
+               std::string plain(65536, 'x');
+               auto compressed = unit_zstd_compress_oneshot(plain);
+
+               GError *err = nullptr;
+               auto *body = rspamd_zstd_decompress_bounded(nullptr, compressed.data(),
+                                                                                                       compressed.size(),
+                                                                                                       plain.size(), &err);
+               REQUIRE(body != nullptr);
+               REQUIRE(err == nullptr);
+               CHECK(body->len == plain.size());
+               rspamd_fstring_free(body);
+       }
+
+       TEST_CASE("advertised size over the limit is rejected upfront")
+       {
+               std::string plain(4 * 1024 * 1024, '\0');
+               auto compressed = unit_zstd_compress_oneshot(plain);
+
+               GError *err = nullptr;
+               auto *body = rspamd_zstd_decompress_bounded(nullptr, compressed.data(),
+                                                                                                       compressed.size(),
+                                                                                                       1024 * 1024, &err);
+               CHECK(body == nullptr);
+               REQUIRE(err != nullptr);
+               CHECK(err->code == RSPAMD_DECOMPRESS_ERROR_TOO_LARGE);
+               g_error_free(err);
+       }
+
+       TEST_CASE("unknown-size frame over the limit is rejected while streaming")
+       {
+               std::string plain(4 * 1024 * 1024, '\0');
+               auto compressed = unit_zstd_compress_stream(plain);
+               /* The frame must not advertise its decompressed size */
+               REQUIRE(ZSTD_getDecompressedSize(compressed.data(), compressed.size()) == 0);
+
+               GError *err = nullptr;
+               auto *body = rspamd_zstd_decompress_bounded(nullptr, compressed.data(),
+                                                                                                       compressed.size(),
+                                                                                                       64 * 1024, &err);
+               CHECK(body == nullptr);
+               REQUIRE(err != nullptr);
+               CHECK(err->code == RSPAMD_DECOMPRESS_ERROR_TOO_LARGE);
+               g_error_free(err);
+       }
+
+       TEST_CASE("unknown-size frame within the limit roundtrips via buffer growth")
+       {
+               std::string plain;
+               for (int i = 0; i < 100000; i++) {
+                       plain += "0123456789";
+               }
+               auto compressed = unit_zstd_compress_stream(plain);
+               REQUIRE(ZSTD_getDecompressedSize(compressed.data(), compressed.size()) == 0);
+
+               GError *err = nullptr;
+               auto *body = rspamd_zstd_decompress_bounded(nullptr, compressed.data(),
+                                                                                                       compressed.size(),
+                                                                                                       2 * plain.size(), &err);
+               REQUIRE(body != nullptr);
+               REQUIRE(err == nullptr);
+               CHECK(std::string{body->str, body->len} == plain);
+               rspamd_fstring_free(body);
+       }
+
+       TEST_CASE("corrupt input is an error")
+       {
+               std::string garbage = "definitely not a zstd frame at all";
+
+               GError *err = nullptr;
+               auto *body = rspamd_zstd_decompress_bounded(nullptr, garbage.data(),
+                                                                                                       garbage.size(), 0, &err);
+               CHECK(body == nullptr);
+               REQUIRE(err != nullptr);
+               CHECK(err->code == RSPAMD_DECOMPRESS_ERROR_DATA);
+               g_error_free(err);
+       }
+
+       TEST_CASE("caller-provided stream is used and not freed")
+       {
+               std::string plain(1024, 'y');
+               auto compressed = unit_zstd_compress_oneshot(plain);
+
+               auto *ds = ZSTD_createDStream();
+               REQUIRE(ds != nullptr);
+               ZSTD_initDStream(ds);
+
+               GError *err = nullptr;
+               auto *body = rspamd_zstd_decompress_bounded(ds, compressed.data(),
+                                                                                                       compressed.size(), 0, &err);
+               REQUIRE(body != nullptr);
+               CHECK(std::string{body->str, body->len} == plain);
+               rspamd_fstring_free(body);
+
+               /* Stream must still be usable for another frame */
+               ZSTD_DCtx_reset(ds, ZSTD_reset_session_only);
+               body = rspamd_zstd_decompress_bounded(ds, compressed.data(),
+                                                                                         compressed.size(), 0, &err);
+               REQUIRE(body != nullptr);
+               CHECK(body->len == plain.size());
+               rspamd_fstring_free(body);
+               ZSTD_freeDStream(ds);
+       }
+}
+
+#endif