From 928014863b6c5749b4b6235a78e0afc810d17d2f Mon Sep 17 00:00:00 2001 From: Davide Angelocola Date: Sat, 27 Jun 2026 18:27:05 +0200 Subject: [PATCH 1/2] harden: wrap oversized declared size as ZstdException; test bomb handling Zstd.decompress(byte[]) reads the (untrusted) content size from the frame header to size its output. A header declaring more than Integer.MAX_VALUE bytes previously let a raw ArithmeticException escape from Math.toIntExact; it now fails with a ZstdException naming the limit and pointing to decompress(byte[], int) for bounded, untrusted-safe decoding. Adds ZstdTest.UntrustedInput documenting decompression-bomb behaviour: - decompress(byte[], maxSize) refuses a tiny frame that expands to 8 MiB, - decompress(byte[]) trusts the declared size and expands fully (so untrusted callers must pass a bound), - a hand-built frame header declaring > 2 GiB throws ZstdException, not ArithmeticException. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 4 ++ .../main/java/io/github/dfa1/zstd/Zstd.java | 18 +++++- .../java/io/github/dfa1/zstd/ZstdTest.java | 60 +++++++++++++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c52e3f..3a21365 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,10 @@ git tags, which trigger publication to Maven Central. `ZstdDictionary.id()`, `ZstdCompressDict.id()`, `ZstdDecompressDict.id()`, `ZstdFrame.dictId(...)`, and `ZstdFrameHeader.dictId()`. The `0` sentinel is now `ZstdDictionaryId.NONE`, and the id reads as unsigned via `value()`. +- `Zstd.decompress(byte[])` now throws `ZstdException` (instead of letting a raw + `ArithmeticException` escape) when a frame declares a content size larger than a + Java array can hold. The size comes from the untrusted frame header; use + `decompress(byte[], int)` to bound output for untrusted input. ## [0.5] diff --git a/zstd/src/main/java/io/github/dfa1/zstd/Zstd.java b/zstd/src/main/java/io/github/dfa1/zstd/Zstd.java index 6fc830c..b6f703c 100644 --- a/zstd/src/main/java/io/github/dfa1/zstd/Zstd.java +++ b/zstd/src/main/java/io/github/dfa1/zstd/Zstd.java @@ -60,7 +60,23 @@ public static byte[] compress(byte[] src, int level) { public static byte[] decompress(byte[] compressed) { Objects.requireNonNull(compressed, "compressed"); long size = requireStoredContentSize(frameContentSize(compressed)); - return decompress(compressed, Math.toIntExact(size)); + return decompress(compressed, toArrayLength(size)); + } + + /// Narrows a frame-declared content size to a `byte[]` length, rejecting sizes + /// that exceed what a Java array can hold. The size comes from the (untrusted) + /// frame header, so this fails with a [ZstdException] rather than letting a raw + /// `ArithmeticException` escape. + /// + /// @param size a non-negative content size from a frame header + /// @return `size` as an `int` + /// @throws ZstdException if `size` exceeds [Integer#MAX_VALUE] + private static int toArrayLength(long size) { + if (size > Integer.MAX_VALUE) { + throw new ZstdException("decompressed size " + size + + " exceeds the maximum array length; use decompress(byte[], int) to bound it"); + } + return (int) size; } /// Decompresses a zstd frame into a buffer of at most `maxSize` bytes. diff --git a/zstd/src/test/java/io/github/dfa1/zstd/ZstdTest.java b/zstd/src/test/java/io/github/dfa1/zstd/ZstdTest.java index dd7a98a..9b3be92 100644 --- a/zstd/src/test/java/io/github/dfa1/zstd/ZstdTest.java +++ b/zstd/src/test/java/io/github/dfa1/zstd/ZstdTest.java @@ -256,4 +256,64 @@ private static byte[] standardDict(int id) { return d; } } + + @Nested + class UntrustedInput { + + @Test + void boundedDecompressRefusesADecompressionBomb() { + // Given a tiny frame that expands enormously (8 MiB of zeros -> a few bytes) + byte[] bomb = Zstd.compress(new byte[8 * 1024 * 1024]); + assertThat(bomb.length).isLessThan(1024); // huge amplification ratio + + // When decompressed with a small bound (the safe path for untrusted input) + ThrowingCallable result = () -> Zstd.decompress(bomb, 64 * 1024); + + // Then it is refused instead of allocating the full expansion + assertThatThrownBy(result).isInstanceOf(ZstdException.class); + } + + @Test + void unboundedDecompressTrustsTheDeclaredSize() { + // Given the same tiny, highly amplifying frame + byte[] bomb = Zstd.compress(new byte[8 * 1024 * 1024]); + + // When decompressed without a bound + byte[] out = Zstd.decompress(bomb); + + // Then it expands fully — documenting that this overload trusts the frame + // header; untrusted callers must use decompress(byte[], maxSize) + assertThat(out).hasSize(8 * 1024 * 1024); + } + + @Test + void rejectsAFrameDeclaringMoreThanArrayMaxWithoutLeakingArithmeticException() { + // Given a frame header that declares a content size above Integer.MAX_VALUE + byte[] frame = frameHeaderDeclaringContentSize(3_000_000_000L); + + // When decompressed via the size-trusting overload + ThrowingCallable result = () -> Zstd.decompress(frame); + + // Then it fails with a ZstdException, not a raw ArithmeticException + assertThatThrownBy(result) + .isInstanceOf(ZstdException.class) + .hasMessageContaining("exceeds the maximum array length"); + } + + // A minimal single-segment zstd frame header (magic + descriptor + 8-byte + // Frame_Content_Size) declaring `contentSize`; enough for the content-size + // read, which happens before any block is decoded. + private static byte[] frameHeaderDeclaringContentSize(long contentSize) { + byte[] f = new byte[13]; + f[0] = (byte) 0x28; // magic 0xFD2FB528, little-endian + f[1] = (byte) 0xB5; + f[2] = (byte) 0x2F; + f[3] = (byte) 0xFD; + f[4] = (byte) 0xE0; // descriptor: FCS_flag=3 (8 bytes) | single-segment + for (int i = 0; i < 8; i++) { // 8-byte little-endian content size + f[5 + i] = (byte) (contentSize >>> (8 * i)); + } + return f; + } + } } From 0c703ee394513b88390a37323602bc65b0977e0e Mon Sep 17 00:00:00 2001 From: Davide Angelocola Date: Sat, 27 Jun 2026 18:36:06 +0200 Subject: [PATCH 2/2] docs: document untrusted-input handling on the decompress overloads Adds a Security note to Zstd.decompress(byte[]) that it trusts the frame's declared content size and can be a decompression-bomb vector, steering untrusted callers to decompress(byte[], int); and notes on the bounded overload that it is the safe entry point for untrusted input. Co-Authored-By: Claude Opus 4.8 --- .../src/main/java/io/github/dfa1/zstd/Zstd.java | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/zstd/src/main/java/io/github/dfa1/zstd/Zstd.java b/zstd/src/main/java/io/github/dfa1/zstd/Zstd.java index b6f703c..7e5a035 100644 --- a/zstd/src/main/java/io/github/dfa1/zstd/Zstd.java +++ b/zstd/src/main/java/io/github/dfa1/zstd/Zstd.java @@ -53,10 +53,18 @@ public static byte[] compress(byte[] src, int level) { /// Decompresses a complete zstd frame whose decompressed size is recorded in /// its header (the case for frames produced by this library). /// + /// **Security:** this overload trusts the content size declared in the frame + /// header and allocates a buffer of that size. The header is part of the input, + /// so a hostile frame can declare a large size (up to the maximum array length) + /// and force a correspondingly large allocation — a decompression-bomb denial of + /// service. For input you do not control, use [#decompress(byte[], int)] with a + /// sane bound instead. + /// /// @param compressed a complete zstd frame /// @return the original bytes - /// @throws ZstdException if the frame is invalid or its content size is not stored; - /// use [#decompress(byte[], int)] for the latter + /// @throws ZstdException if the frame is invalid, its content size is not stored + /// (use [#decompress(byte[], int)] for the latter), or the + /// declared size exceeds the maximum array length public static byte[] decompress(byte[] compressed) { Objects.requireNonNull(compressed, "compressed"); long size = requireStoredContentSize(frameContentSize(compressed)); @@ -82,6 +90,11 @@ private static int toArrayLength(long size) { /// Decompresses a zstd frame into a buffer of at most `maxSize` bytes. /// Use this when the original size is known out-of-band or not stored in the frame. /// + /// This is the safe entry point for **untrusted** input: `maxSize` caps the + /// allocation and decode, so a hostile frame cannot trigger an oversized + /// allocation the way [#decompress(byte[])] can. Pick a bound your caller can + /// afford; the frame is rejected if its content exceeds it. + /// /// @param compressed a complete zstd frame /// @param maxSize upper bound on the decompressed length /// @return the original bytes (length ≤ `maxSize`)