Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
35 changes: 32 additions & 3 deletions zstd/src/main/java/io/github/dfa1/zstd/Zstd.java
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
60 changes: 60 additions & 0 deletions zstd/src/test/java/io/github/dfa1/zstd/ZstdTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
Loading