Skip to content

Commit 9280148

Browse files
dfa1claude
andcommitted
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 <noreply@anthropic.com>
1 parent 367556e commit 9280148

3 files changed

Lines changed: 81 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ git tags, which trigger publication to Maven Central.
3838
`ZstdDictionary.id()`, `ZstdCompressDict.id()`, `ZstdDecompressDict.id()`,
3939
`ZstdFrame.dictId(...)`, and `ZstdFrameHeader.dictId()`. The `0` sentinel is now
4040
`ZstdDictionaryId.NONE`, and the id reads as unsigned via `value()`.
41+
- `Zstd.decompress(byte[])` now throws `ZstdException` (instead of letting a raw
42+
`ArithmeticException` escape) when a frame declares a content size larger than a
43+
Java array can hold. The size comes from the untrusted frame header; use
44+
`decompress(byte[], int)` to bound output for untrusted input.
4145

4246
## [0.5]
4347

zstd/src/main/java/io/github/dfa1/zstd/Zstd.java

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,23 @@ public static byte[] compress(byte[] src, int level) {
6060
public static byte[] decompress(byte[] compressed) {
6161
Objects.requireNonNull(compressed, "compressed");
6262
long size = requireStoredContentSize(frameContentSize(compressed));
63-
return decompress(compressed, Math.toIntExact(size));
63+
return decompress(compressed, toArrayLength(size));
64+
}
65+
66+
/// Narrows a frame-declared content size to a `byte[]` length, rejecting sizes
67+
/// that exceed what a Java array can hold. The size comes from the (untrusted)
68+
/// frame header, so this fails with a [ZstdException] rather than letting a raw
69+
/// `ArithmeticException` escape.
70+
///
71+
/// @param size a non-negative content size from a frame header
72+
/// @return `size` as an `int`
73+
/// @throws ZstdException if `size` exceeds [Integer#MAX_VALUE]
74+
private static int toArrayLength(long size) {
75+
if (size > Integer.MAX_VALUE) {
76+
throw new ZstdException("decompressed size " + size
77+
+ " exceeds the maximum array length; use decompress(byte[], int) to bound it");
78+
}
79+
return (int) size;
6480
}
6581

6682
/// Decompresses a zstd frame into a buffer of at most `maxSize` bytes.

zstd/src/test/java/io/github/dfa1/zstd/ZstdTest.java

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,4 +256,64 @@ private static byte[] standardDict(int id) {
256256
return d;
257257
}
258258
}
259+
260+
@Nested
261+
class UntrustedInput {
262+
263+
@Test
264+
void boundedDecompressRefusesADecompressionBomb() {
265+
// Given a tiny frame that expands enormously (8 MiB of zeros -> a few bytes)
266+
byte[] bomb = Zstd.compress(new byte[8 * 1024 * 1024]);
267+
assertThat(bomb.length).isLessThan(1024); // huge amplification ratio
268+
269+
// When decompressed with a small bound (the safe path for untrusted input)
270+
ThrowingCallable result = () -> Zstd.decompress(bomb, 64 * 1024);
271+
272+
// Then it is refused instead of allocating the full expansion
273+
assertThatThrownBy(result).isInstanceOf(ZstdException.class);
274+
}
275+
276+
@Test
277+
void unboundedDecompressTrustsTheDeclaredSize() {
278+
// Given the same tiny, highly amplifying frame
279+
byte[] bomb = Zstd.compress(new byte[8 * 1024 * 1024]);
280+
281+
// When decompressed without a bound
282+
byte[] out = Zstd.decompress(bomb);
283+
284+
// Then it expands fully — documenting that this overload trusts the frame
285+
// header; untrusted callers must use decompress(byte[], maxSize)
286+
assertThat(out).hasSize(8 * 1024 * 1024);
287+
}
288+
289+
@Test
290+
void rejectsAFrameDeclaringMoreThanArrayMaxWithoutLeakingArithmeticException() {
291+
// Given a frame header that declares a content size above Integer.MAX_VALUE
292+
byte[] frame = frameHeaderDeclaringContentSize(3_000_000_000L);
293+
294+
// When decompressed via the size-trusting overload
295+
ThrowingCallable result = () -> Zstd.decompress(frame);
296+
297+
// Then it fails with a ZstdException, not a raw ArithmeticException
298+
assertThatThrownBy(result)
299+
.isInstanceOf(ZstdException.class)
300+
.hasMessageContaining("exceeds the maximum array length");
301+
}
302+
303+
// A minimal single-segment zstd frame header (magic + descriptor + 8-byte
304+
// Frame_Content_Size) declaring `contentSize`; enough for the content-size
305+
// read, which happens before any block is decoded.
306+
private static byte[] frameHeaderDeclaringContentSize(long contentSize) {
307+
byte[] f = new byte[13];
308+
f[0] = (byte) 0x28; // magic 0xFD2FB528, little-endian
309+
f[1] = (byte) 0xB5;
310+
f[2] = (byte) 0x2F;
311+
f[3] = (byte) 0xFD;
312+
f[4] = (byte) 0xE0; // descriptor: FCS_flag=3 (8 bytes) | single-segment
313+
for (int i = 0; i < 8; i++) { // 8-byte little-endian content size
314+
f[5 + i] = (byte) (contentSize >>> (8 * i));
315+
}
316+
return f;
317+
}
318+
}
259319
}

0 commit comments

Comments
 (0)