From e48f19ee2bb8d0472b6aa6ac8776b320e612de3a Mon Sep 17 00:00:00 2001 From: TapTap Date: Mon, 14 Sep 2026 16:39:05 +0200 Subject: [PATCH] fix(compression): fail truncated zstd frames instead of spinning data_decompress_limited() looped while ZSTD_decompressStream() returned a positive hint. A truncated frame keeps returning that hint with all input consumed, so a malformed/truncated payload spun forever (CPU DoS). Detect input exhaustion with an incomplete frame and fail via the existing cleanup, skipping the check when the output buffer merely needs to grow first. Add a fork+alarm regression test that truncates a valid frame and asserts decompression returns NULL promptly. --- src/shared/compression.c | 14 ++++++++++++++ tests/test_compression.c | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/src/shared/compression.c b/src/shared/compression.c index 7609921..c40c534 100644 --- a/src/shared/compression.c +++ b/src/shared/compression.c @@ -324,6 +324,20 @@ Data* data_decompress_limited(Data* compressed_data, size_t maximum_size) { uncompressed_data->data = new_data; output.dst = new_data; output.size = buf_size; + /* Re-attempt with the larger output buffer; the truncated-frame check + * below must not reject a complete frame that merely filled the previous + * buffer exactly. */ + continue; + } + /* A positive hint with all input consumed means the frame is incomplete: a + * truncated stream would otherwise spin here forever (ZSTD_decompressStream + * keeps returning the same hint). Fail instead of burning CPU. */ + if (ret != 0 && input.pos == input.size) { + log_message(LOG_LEVEL_ERROR, + "Truncated zstd frame: input exhausted with %zu bytes still expected", ret); + data_destroy(uncompressed_data); + uncompressed_data = NULL; + goto cleanup; } } while (ret > 0); diff --git a/tests/test_compression.c b/tests/test_compression.c index da51531..2ebb8da 100644 --- a/tests/test_compression.c +++ b/tests/test_compression.c @@ -6,6 +6,7 @@ #include "utils.h" #include #include +#include #include #include @@ -211,9 +212,47 @@ static void test_data_compress_reused_contexts_multithreaded() { compression_free_thread_contexts(); } +/* A truncated zstd frame used to make the decompressor spin forever: the + * stream call keeps returning a positive hint with all input consumed. Run the + * decompression in a child with an alarm so a regression (infinite loop) is + * caught as a timeout failure instead of hanging the whole unit suite. */ +static void test_data_decompress_truncated_frame_fails() { + const char* original = + "The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog."; + size_t len = strlen(original); + char* buf = malloc(len); + EXPECT_NOT_NULL(buf); + memcpy(buf, original, len); + Data* input = data_create(buf, len); + EXPECT_NOT_NULL(input); + + pid_t pid = fork(); + EXPECT_TRUE(pid >= 0); + if (pid == 0) { + alarm(10); /* kills the child if the decompressor hangs */ + Data* compressed = data_compress(input, 3); + if (compressed && compressed->size > 1) { + compressed->size -= 1; /* drop the final byte: frame is now incomplete */ + Data* out = data_decompress(compressed); + bool failed_cleanly = (out == NULL); + data_destroy(out); + data_destroy(compressed); + _exit(failed_cleanly ? 0 : 1); + } + data_destroy(compressed); + _exit(2); + } + int status; + waitpid(pid, &status, 0); + EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0); + + data_destroy(input); +} + void test_compression() { test_data_compress_decompress_roundtrip(); test_data_compress_decompress_large(); + test_data_decompress_truncated_frame_fails(); test_skip_compress_suffix_matching(); test_data_compress_with_threads_roundtrip(); test_data_compress_reused_contexts_multithreaded();