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..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,19 +53,48 @@ 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)); - 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. /// 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`) 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; + } + } }