diff --git a/mina-filter-compression/src/main/java/org/apache/mina/filter/compression/CompressionFilter.java b/mina-filter-compression/src/main/java/org/apache/mina/filter/compression/CompressionFilter.java index 55b21337d..f6e174771 100644 --- a/mina-filter-compression/src/main/java/org/apache/mina/filter/compression/CompressionFilter.java +++ b/mina-filter-compression/src/main/java/org/apache/mina/filter/compression/CompressionFilter.java @@ -104,16 +104,22 @@ public class CompressionFilter extends IoFilterAdapter { private boolean compressOutbound = true; private int compressionLevel; - + /** The maximum decompressed size, to avoid an OOM. Default to 1Mb */ private int maxDecompressedSize; + /** Maximum decompression ratio **/ + private final long maxDecompressRatio; + + /** Grace size before decompression ratio check is enforced **/ + private final long decompressRatioMinSize; + /** * Creates a new instance which compresses outboud data and decompresses * inbound data with default compression level. */ public CompressionFilter() { - this(true, true, COMPRESSION_DEFAULT, Zlib.MAX_DECOMPRESSED_SIZE); + this(true, true, COMPRESSION_DEFAULT, Zlib.MAX_DECOMPRESSED_SIZE, Zlib.MAX_DECOMPRESS_RATIO, Zlib.DECOMPRESS_RATIO_MIN_SIZE); } /** @@ -127,7 +133,7 @@ public CompressionFilter() { * {@link #COMPRESSION_NONE}. */ public CompressionFilter(final int compressionLevel) { - this(true, true, compressionLevel, Zlib.MAX_DECOMPRESSED_SIZE); + this(true, true, compressionLevel, Zlib.MAX_DECOMPRESSED_SIZE, Zlib.MAX_DECOMPRESS_RATIO, Zlib.DECOMPRESS_RATIO_MIN_SIZE); } /** @@ -143,7 +149,7 @@ public CompressionFilter(final int compressionLevel) { */ public CompressionFilter(final boolean compressInbound, final boolean compressOutbound, final int compressionLevel) { - this(true, true, compressionLevel, Zlib.MAX_DECOMPRESSED_SIZE); + this(compressInbound, compressOutbound, compressionLevel, Zlib.MAX_DECOMPRESSED_SIZE, Zlib.MAX_DECOMPRESS_RATIO, Zlib.DECOMPRESS_RATIO_MIN_SIZE); } /** @@ -159,15 +165,43 @@ public CompressionFilter(final boolean compressInbound, final boolean compressOu * {@link #COMPRESSION_MIN}, and * {@link #COMPRESSION_NONE}. * @param maxDecompressedSize The maximum size for a buffer when inflating some data + * @since 2.2.8 */ - public CompressionFilter(final boolean compressInbound, final boolean compressOutbound, + public CompressionFilter(final boolean compressInbound, final boolean compressOutbound, final int compressionLevel, final int maxDecompressedSize) { + this(compressInbound, compressOutbound, compressionLevel, maxDecompressedSize, Zlib.MAX_DECOMPRESS_RATIO, Zlib.DECOMPRESS_RATIO_MIN_SIZE); + } + + /** + * Creates a new instance with explicit zip-bomb protection parameters. + * + * @param compressInbound true if data read is to be decompressed + * @param compressOutbound true if data written is to be compressed + * @param compressionLevel the level of compression to be used. Must + * be one of {@link #COMPRESSION_DEFAULT}, + * {@link #COMPRESSION_MAX}, + * {@link #COMPRESSION_MIN}, and + * {@link #COMPRESSION_NONE}. + * @param maxDecompressedSize the maximum size for a buffer when inflating data + * @param maxDecompressRatio the maximum allowed cumulative ratio of + * decompressed to compressed bytes. + * A value <= 0 disables the check. + * @param decompressRatioMinSize the minimum cumulative decompressed size + * below which the ratio check is skipped. + * @since 2.2.8 + */ + public CompressionFilter(final boolean compressInbound, final boolean compressOutbound, + final int compressionLevel, final int maxDecompressedSize, + final long maxDecompressRatio, final long decompressRatioMinSize) { this.compressionLevel = compressionLevel; this.compressInbound = compressInbound; this.compressOutbound = compressOutbound; this.maxDecompressedSize = maxDecompressedSize; + this.maxDecompressRatio = maxDecompressRatio; + this.decompressRatioMinSize = decompressRatioMinSize; } - + + /** * {@inheritDoc} */ @@ -239,8 +273,8 @@ public void onPreAdd(IoFilterChain parent, String name, NextFilter nextFilter) t throw new IllegalStateException("Only one " + CompressionFilter.class + " is permitted."); } - Zlib deflater = new Zlib(compressionLevel, Zlib.MODE_DEFLATER); - Zlib inflater = new Zlib(compressionLevel, Zlib.MODE_INFLATER, maxDecompressedSize); + Zlib deflater = new Zlib(compressionLevel, Zlib.MODE_INFLATER, maxDecompressedSize, maxDecompressRatio, decompressRatioMinSize); + Zlib inflater = new Zlib(compressionLevel, Zlib.MODE_INFLATER, maxDecompressedSize, maxDecompressRatio, decompressRatioMinSize); IoSession session = parent.getSession(); diff --git a/mina-filter-compression/src/main/java/org/apache/mina/filter/compression/Zlib.java b/mina-filter-compression/src/main/java/org/apache/mina/filter/compression/Zlib.java index 44f315b76..c78abe63b 100644 --- a/mina-filter-compression/src/main/java/org/apache/mina/filter/compression/Zlib.java +++ b/mina-filter-compression/src/main/java/org/apache/mina/filter/compression/Zlib.java @@ -54,13 +54,32 @@ class Zlib { /** The requested compression level */ private int compressionLevel; - + /** The maximum size of an inflated buffer. Default to 1Mb */ - /* Package protected */ + /* Package protected */ static final int MAX_DECOMPRESSED_SIZE = Integer.MAX_VALUE; + /** + * Default maximum decompression ratio (decompressed / compressed). + */ + /* Package protected */ + static final long MAX_DECOMPRESS_RATIO = 100L; + + /** + * Grace size before decompression ratio check is enforced. + * + *

Below this threshold the check is skipped to avoid false positives on small payloads where framing/header + * overhead dominates the ratio.

+ */ + /* Package protected */ + static final long DECOMPRESS_RATIO_MIN_SIZE = 1024L * 1024L; + private int maxDecompressedSize = MAX_DECOMPRESSED_SIZE; + private long maxDecompressRatio = MAX_DECOMPRESS_RATIO; + + private long decompressRatioMinSize = DECOMPRESS_RATIO_MIN_SIZE; + /** The inner stream used to inflate or deflate the data */ private ZStream zStream = null; @@ -78,47 +97,28 @@ class Zlib { * @throws IllegalArgumentException if the mode is incorrect */ public Zlib(int compressionLevel, int mode) { - switch (compressionLevel) { - case COMPRESSION_MAX: - case COMPRESSION_MIN: - case COMPRESSION_NONE: - case COMPRESSION_DEFAULT: - this.compressionLevel = compressionLevel; - break; - default: - throw new IllegalArgumentException("invalid compression level specified"); - } - - // create a new instance of ZStream. This will be done only once. - zStream = new ZStream(); - - switch (mode) { - case MODE_DEFLATER: - zStream.deflateInit(this.compressionLevel); - break; - case MODE_INFLATER: - zStream.inflateInit(); - break; - default: - throw new IllegalArgumentException("invalid mode specified"); - } - - this.mode = mode; + this(compressionLevel, mode, MAX_DECOMPRESSED_SIZE, MAX_DECOMPRESS_RATIO, DECOMPRESS_RATIO_MIN_SIZE); } - + /** * Creates an instance of the ZLib class. - * + * * @param compressionLevel the level of compression that should be used. One of * COMPRESSION_MAX, COMPRESSION_MIN, * COMPRESSION_NONE or COMPRESSION_DEFAULT * @param mode the mode in which the instance will operate. Can be either * of MODE_DEFLATER or MODE_INFLATER - * @param maxDecompressedSize The maximum inflation size for a buffer. Default to 1MB + * @param maxDecompressedSize the maximum inflation size for a buffer + * @param maxDecompressRatio the maximum allowed ratio of decompressed to + * compressed bytes, evaluated cumulatively over the lifetime of this + * inflater. A value <= 0 disables the check. + * @param decompressRatioMinSize the minimum cumulative decompressed size + * (in bytes) below which the ratio check is skipped. * @throws IllegalArgumentException if the mode is incorrect */ - public Zlib(int compressionLevel, int mode, int maxDecompressedSize) { + public Zlib(int compressionLevel, int mode, int maxDecompressedSize, + long maxDecompressRatio, long decompressRatioMinSize) { switch (compressionLevel) { case COMPRESSION_MAX: case COMPRESSION_MIN: @@ -139,6 +139,8 @@ public Zlib(int compressionLevel, int mode, int maxDecompressedSize) { break; case MODE_INFLATER: this.maxDecompressedSize = maxDecompressedSize; + this.maxDecompressRatio = maxDecompressRatio; + this.decompressRatioMinSize = decompressRatioMinSize; zStream.inflateInit(); break; default: @@ -147,7 +149,7 @@ public Zlib(int compressionLevel, int mode, int maxDecompressedSize) { this.mode = mode; } - + /** * Uncompress the given buffer, returning it in a new buffer. @@ -188,12 +190,14 @@ public IoBuffer inflate(IoBuffer inBuffer) throws IOException { case JZlib.Z_OK: // completed decompression, lets copy data and get out case JZlib.Z_BUF_ERROR: - // Try to avoid exhausting the JVM memory by controling the resulting buffer + // Try to avoid exhausting the JVM memory by controling the resulting buffer // size after inflation if (outBuffer.position() + zStream.next_out_index > maxDecompressedSize) { throw new IOException("decompressed size exceeds max " + maxDecompressedSize); } - + + checkDecompressRatio(); + // need more space for output. store current output and get more outBuffer.put(outBytes, 0, zStream.next_out_index); zStream.next_out_index = 0; @@ -262,6 +266,22 @@ public IoBuffer deflate(IoBuffer inBuffer) throws IOException { } } + /** + * Checks the cumulative decompression ratio against the configured maximum. + * + * @throws IOException if the cumulative ratio exceeds {@code maxDecompressRatio} + */ + private void checkDecompressRatio() throws IOException { + if (maxDecompressRatio <= 0L) { + return; + } + long totalOut = zStream.getTotalOut(); + long totalIn = zStream.getTotalIn(); + if (totalIn > 0L && totalOut > decompressRatioMinSize && totalOut / totalIn > maxDecompressRatio) { + throw new IOException("decompression ratio " + (totalOut / totalIn) + " exceeds max " + maxDecompressRatio); + } + } + /** * Cleans up the resources used by the compression library. */ diff --git a/mina-filter-compression/src/test/java/org/apache/mina/filter/compression/ZlibTest.java b/mina-filter-compression/src/test/java/org/apache/mina/filter/compression/ZlibTest.java index af053e1b0..c28a24abc 100644 --- a/mina-filter-compression/src/test/java/org/apache/mina/filter/compression/ZlibTest.java +++ b/mina-filter-compression/src/test/java/org/apache/mina/filter/compression/ZlibTest.java @@ -21,10 +21,12 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertThrows; import java.io.IOException; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; +import java.util.Random; import org.apache.mina.core.buffer.IoBuffer; import org.junit.Before; @@ -44,6 +46,26 @@ public void setUp() throws Exception { inflater = new Zlib(Zlib.COMPRESSION_MAX, Zlib.MODE_INFLATER); } + private IoBuffer deflateZeros(int size) throws IOException { + try { + return new Zlib(Zlib.COMPRESSION_MAX, Zlib.MODE_DEFLATER) + .deflate(IoBuffer.wrap(new byte[size])); + } catch (Exception e) { + throw new AssertionError("failed to deflate test fixture", e); + } + } + + private IoBuffer deflateRandom(int size) throws IOException { + try { + byte[] data = new byte[size]; + new Random(0).nextBytes(data); + return new Zlib(Zlib.COMPRESSION_MAX, Zlib.MODE_DEFLATER) + .deflate(IoBuffer.wrap(data)); + } catch (Exception e) { + throw new AssertionError("failed to deflate test fixture", e); + } + } + @Test public void testCompression() throws Exception { String strInput = ""; @@ -135,25 +157,23 @@ public void testFragments() throws Exception { *
  • A 1MB buffer that once compressed should inflate properly *
  • A 10MB buffer that once compressed should inflate properly *
  • - * + * * @throws Exception */ @Test public void testZBombDataNoLimit() throws Exception { - // Create an inflater with no size limit - Zlib inflaterNoLimit = new Zlib(Zlib.COMPRESSION_MAX, Zlib.MODE_INFLATER); + // Create an inflater with no size limit and the ratio check disabled + Zlib inflaterNoLimit = new Zlib(Zlib.COMPRESSION_MAX, Zlib.MODE_INFLATER, + Zlib.MAX_DECOMPRESSED_SIZE, 0L, 0L); // Try a 10MB buffer bomb. Should succeed - byte[] uncompressed = new byte[1_024*1_024*10]; - - IoBuffer byteInput = IoBuffer.wrap(uncompressed); - IoBuffer byteCompressed = deflater.deflate(byteInput); - + IoBuffer byteCompressed = deflateZeros(1_024 * 1_024 * 10); + // Should be fine inflaterNoLimit.inflate(byteCompressed); } - + /** * Test the inflater default limit. * We create buffers of various sizes: @@ -161,29 +181,117 @@ public void testZBombDataNoLimit() throws Exception { *
  • A 1MB Buffer that once compressed should inflate properly *
  • A 1MB+1byte buffer that once compressed should generate an exception when inflated *
  • - * + * * @throws Exception */ - @Test(expected=IOException.class) + @Test public void testZBombData() throws Exception { - // Create an inflater with a 1Mb size limit - Zlib inflaterWithLimit = new Zlib(Zlib.COMPRESSION_MAX, Zlib.MODE_INFLATER, 1_024*1_024); + // Create an inflater with a 1Mb size limit and the ratio check disabled + // so this test stays focused on the size limit. + Zlib inflaterWithLimit = new Zlib(Zlib.COMPRESSION_MAX, Zlib.MODE_INFLATER, 1_024*1_024, 0L, 0L); - // Try a 1MB buffer bomb. Should succeed - byte[] uncompressed = new byte[1_024*1_024]; - - IoBuffer byteInput = IoBuffer.wrap(uncompressed); - IoBuffer byteCompressed = deflater.deflate(byteInput); - - // Should be fine - inflaterWithLimit.inflate(byteCompressed); - - // Now try with a 1Mb +1 byte buffer - uncompressed = new byte[1_024*1_024+1]; - byteInput = IoBuffer.wrap(uncompressed); - byteCompressed = deflater.deflate(byteInput); - - // Should now fail and throw a IoException - inflaterWithLimit.inflate(byteCompressed); + // Both inputs are fed to the same inflater as a continuous zlib + // stream, so use the shared deflater rather than the fresh-stream + // deflateZeros() helper. + + // Right at the size limit: should succeed. + inflaterWithLimit.inflate(deflater.deflate(IoBuffer.wrap(new byte[1_024 * 1_024]))); + + // One byte over the size limit: should throw. + IoBuffer overLimit = deflater.deflate(IoBuffer.wrap(new byte[1_024 * 1_024 + 1])); + assertThrows(IOException.class, () -> inflaterWithLimit.inflate(overLimit)); + } + + + /** + * A highly compressible payload that exceeds both the default ratio (100) + * and the default ratio min-size threshold should be rejected by the + * inflater. + */ + @Test + public void testDecompressRatioExceeded() throws Exception { + // 64KiB of zeros compresses to well under 64KiB/100 bytes. + int size = 64 * 1_024; + IoBuffer byteCompressed = deflateZeros(size); + int compressedSize = byteCompressed.remaining(); + long actualCompressRatio = size / compressedSize; + + // Inflater configured one ratio step below the actual payload's + // ratio: the inflate call must throw. + Zlib inflater = new Zlib(Zlib.COMPRESSION_MAX, Zlib.MODE_INFLATER, Zlib.MAX_DECOMPRESSED_SIZE, actualCompressRatio - 1, 0L); + assertThrows(IOException.class, () -> inflater.inflate(byteCompressed)); + } + + + /** + * The ratio check must not fire while the cumulative decompressed size is + * below the configured min-size threshold. + */ + @Test + public void testDecompressRatioBelowMinSize() throws Exception { + int size = 1_024 * 1_024; + IoBuffer byteCompressed = deflateZeros(size); + + // Ratio of 100 would normally trip on this payload; raise the min-size + // threshold above the payload so the check is skipped. + Zlib inflater = new Zlib(Zlib.COMPRESSION_MAX, Zlib.MODE_INFLATER, Zlib.MAX_DECOMPRESSED_SIZE, 1L, size); + inflater.inflate(byteCompressed); + } + + + /** + * The ratio check is cumulative across multiple inflate() calls on the + * same stream, so a bomb cannot bypass it by being split into small + * fragments. + */ + @Test + public void testDecompressRatioCumulative() throws Exception { + Zlib inflater = new Zlib(Zlib.COMPRESSION_MAX, Zlib.MODE_INFLATER, Zlib.MAX_DECOMPRESSED_SIZE, 1L, Zlib.DECOMPRESS_RATIO_MIN_SIZE); + + // Below the min-size gate + int chunkSize = (int) Zlib.DECOMPRESS_RATIO_MIN_SIZE; + inflater.inflate(deflater.deflate(IoBuffer.wrap(new byte[chunkSize]))); + + // Exceeds the min-size gate + IoBuffer second = deflater.deflate(IoBuffer.wrap(new byte[chunkSize])); + assertThrows(IOException.class, () -> inflater.inflate(second)); + } + + + /** + * An empty input buffer produces no decompressed output, so the ratio + * check must not fire even with a pathologically tight max ratio of 1 + * and the min-size gate wide open. + */ + @Test + public void testInflateEmptyBuffer() throws Exception { + Zlib inflater = new Zlib(Zlib.COMPRESSION_MAX, Zlib.MODE_INFLATER, Zlib.MAX_DECOMPRESSED_SIZE, 1L, 0L); + inflater.inflate(IoBuffer.allocate(0)); + } + + + /** + * The default-constructor inflater must apply the documented defaults + * (max ratio = 100, min-size gate = 1 MiB). Three legs: + * + */ + @Test + public void testDefaults() throws Exception { + // Leg 1: small + high ratio. Below the 1 MiB gate, check is skipped. + Zlib smallHighRatio = new Zlib(Zlib.COMPRESSION_MAX, Zlib.MODE_INFLATER); + smallHighRatio.inflate(deflateZeros(512 * 1_024)); + + // Leg 2: large + low ratio. Above the gate, but ratio ≈ 1 << 100. + Zlib largeLowRatio = new Zlib(Zlib.COMPRESSION_MAX, Zlib.MODE_INFLATER); + largeLowRatio.inflate(deflateRandom(2 * 1_024 * 1_024)); + + // Leg 3: large + high ratio. Above the gate, ratio >> 100, throws. + Zlib largeHighRatio = new Zlib(Zlib.COMPRESSION_MAX, Zlib.MODE_INFLATER); + IoBuffer bomb = deflateZeros(2 * 1_024 * 1_024); + assertThrows(IOException.class, () -> largeHighRatio.inflate(bomb)); } }