diff --git a/java/org/brotli/wrapper/dec/BUILD.bazel b/java/org/brotli/wrapper/dec/BUILD.bazel index db7b34153..052358c65 100644 --- a/java/org/brotli/wrapper/dec/BUILD.bazel +++ b/java/org/brotli/wrapper/dec/BUILD.bazel @@ -117,3 +117,16 @@ java_test( test_class = "org.brotli.wrapper.dec.DecoderJNITest", runtime_deps = [":test_lib"], ) + +java_test( + name = "DecompressLimitTest", + size = "large", + data = [ + ":brotli_jni", # Bazel JNI workaround + ], + jvm_flags = [ + "-DBROTLI_JNI_LIBRARY=$(location :brotli_jni)", + ], + test_class = "org.brotli.wrapper.dec.DecompressLimitTest", + runtime_deps = [":test_lib"], +) diff --git a/java/org/brotli/wrapper/dec/Decoder.java b/java/org/brotli/wrapper/dec/Decoder.java index 24c7a2731..0fe459296 100644 --- a/java/org/brotli/wrapper/dec/Decoder.java +++ b/java/org/brotli/wrapper/dec/Decoder.java @@ -23,6 +23,28 @@ public class Decoder implements AutoCloseable { boolean closed; boolean eager; + /** Decoder configuration. */ + public static final class Parameters { + private int maxOutputSize; + + public Parameters() { } + + /** + * Limits the total decompressed output produced by {@link #decompress}. + * + *

Use this to mitigate decompression bombs when decoding untrusted input: + * {@code decompress} throws {@link IOException} as soon as the decoded size + * would exceed {@code maxOutputSize}. + * + * @param maxOutputSize maximum total decompressed bytes; {@code 0} (the + * default) or negative means no limit + */ + public Parameters setMaxOutputSize(int maxOutputSize) { + this.maxOutputSize = maxOutputSize; + return this; + } + } + /** * Creates a Decoder wrapper. * @@ -141,6 +163,16 @@ public void close() throws IOException { /** Decodes the given data buffer starting at offset till length. */ public static byte[] decompress(byte[] data, int offset, int length) throws IOException { + return decompress(data, offset, length, new Parameters()); + } + + /** + * Decodes the given data buffer starting at offset till length, honoring the + * given decoder {@code params}. + */ + public static byte[] decompress(byte[] data, int offset, int length, Parameters params) + throws IOException { + int maxOutputSize = params.maxOutputSize; ArrayList output = new ArrayList<>(); int totalOutputSize = 0; DecoderJNI.Wrapper decoder = new DecoderJNI.Wrapper(length); @@ -159,6 +191,11 @@ public static byte[] decompress(byte[] data, int offset, int length) throws IOEx buffer.get(chunk); output.add(chunk); totalOutputSize += chunk.length; + // BrotliDecoderTakeOutput bounds a single chunk, so this may overshoot + // by at most one chunk before aborting - enough to stop a bomb. + if (maxOutputSize > 0 && totalOutputSize > maxOutputSize) { + throw new IOException("decompressed size exceeds maximum size " + maxOutputSize); + } break; case NEEDS_MORE_INPUT: @@ -191,6 +228,11 @@ public static byte[] decompress(byte[] data, int offset, int length) throws IOEx /** Decodes the given data buffer. */ public static byte[] decompress(byte[] data) throws IOException { - return decompress(data, 0, data.length); + return decompress(data, 0, data.length, new Parameters()); + } + + /** Decodes the given data buffer honoring the given decoder {@code params}. */ + public static byte[] decompress(byte[] data, Parameters params) throws IOException { + return decompress(data, 0, data.length, params); } } diff --git a/java/org/brotli/wrapper/dec/DecompressLimitTest.java b/java/org/brotli/wrapper/dec/DecompressLimitTest.java new file mode 100644 index 000000000..86fb6cacf --- /dev/null +++ b/java/org/brotli/wrapper/dec/DecompressLimitTest.java @@ -0,0 +1,64 @@ +/* Copyright 2026 Google Inc. All Rights Reserved. + + Distributed under MIT license. + See file LICENSE for detail or copy at https://opensource.org/licenses/MIT +*/ + +package org.brotli.wrapper.dec; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.io.IOException; +import java.util.Arrays; +import org.brotli.integration.BrotliJniTestBase; +import org.brotli.wrapper.enc.Encoder; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Tests for the {@link Decoder#decompress} output-size limit. */ +@RunWith(JUnit4.class) +public class DecompressLimitTest extends BrotliJniTestBase { + + /** Decompression aborts once the output would exceed the limit. */ + @Test + public void rejectsOutputExceedingLimit() throws IOException { + byte[] original = new byte[64 * 1024]; + Arrays.fill(original, (byte) 'a'); + byte[] compressed = Encoder.compress(original); + + Decoder.Parameters params = new Decoder.Parameters().setMaxOutputSize(1024); + try { + Decoder.decompress(compressed, params); + fail("expected IOException for output exceeding maximum size"); + } catch (IOException ex) { + assertTrue("unexpected message: " + ex.getMessage(), + ex.getMessage().contains("maximum size")); + } + } + + /** Output that fits the limit is returned unchanged. */ + @Test + public void allowsOutputWithinLimit() throws IOException { + byte[] original = new byte[8 * 1024]; + Arrays.fill(original, (byte) 'b'); + byte[] compressed = Encoder.compress(original); + + Decoder.Parameters params = new Decoder.Parameters().setMaxOutputSize(original.length); + byte[] result = Decoder.decompress(compressed, params); + assertArrayEquals(original, result); + } + + /** Default parameters impose no limit. */ + @Test + public void unlimitedByDefault() throws IOException { + byte[] original = new byte[64 * 1024]; + Arrays.fill(original, (byte) 'c'); + byte[] compressed = Encoder.compress(original); + + byte[] result = Decoder.decompress(compressed, new Decoder.Parameters()); + assertArrayEquals(original, result); + } +}