From 63cf1a30d9471c3d612b6a1911bcf5c9f85e0600 Mon Sep 17 00:00:00 2001 From: jmestwa-coder Date: Wed, 13 May 2026 22:50:38 +0530 Subject: [PATCH] boundary handling and decompression limits in CompressedRTF --- .../org/apache/poi/hmef/CompressedRTF.java | 83 ++++++++++++++++++- .../apache/poi/hmef/TestCompressedRTF.java | 42 ++++++++++ 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/poi-scratchpad/src/main/java/org/apache/poi/hmef/CompressedRTF.java b/poi-scratchpad/src/main/java/org/apache/poi/hmef/CompressedRTF.java index 46988b9ec02..9f5d9c2e1e6 100644 --- a/poi-scratchpad/src/main/java/org/apache/poi/hmef/CompressedRTF.java +++ b/poi-scratchpad/src/main/java/org/apache/poi/hmef/CompressedRTF.java @@ -22,9 +22,11 @@ Licensed to the Apache Software Foundation (ASF) under one or more import java.io.OutputStream; import java.nio.charset.StandardCharsets; +import org.apache.commons.io.input.BoundedInputStream; import org.apache.poi.util.IOUtils; import org.apache.poi.util.LZWDecompresser; import org.apache.poi.util.LittleEndian; +import org.apache.poi.util.RecordFormatException; /** @@ -52,6 +54,16 @@ public final class CompressedRTF extends LZWDecompresser { "\\fmodern \\fscript \\fdecor MS Sans SerifSymbolArialTimes New RomanCourier" + "{\\colortbl\\red0\\green0\\blue0\n\r\\par \\pard\\plain\\f0\\fs20\\b\\i\\u\\tab\\tx"; + private static final int DEFAULT_MAX_RECORD_LENGTH = 50_000_000; + /** + * MS-OXRTFCP uses 8-item chunks per control byte. If the data ends mid-chunk, + * the remaining bits in the control byte are processed. If they are literal bits, + * they produce trailing literal bytes from the stream. A chunk can thus emit + * up to 7 bytes of padding beyond the declared uncompressed size. + */ + private static final int MAX_PADDING_LENGTH = 7; + private static int MAX_RECORD_LENGTH = DEFAULT_MAX_RECORD_LENGTH; + private int compressedSize; private int decompressedSize; @@ -62,6 +74,20 @@ public CompressedRTF() { super(true, 2, true); } + /** + * @param length the max decompressed record length allowed for CompressedRTF + */ + public static void setMaxRecordLength(int length) { + MAX_RECORD_LENGTH = length; + } + + /** + * @return the max decompressed record length allowed for CompressedRTF + */ + public static int getMaxRecordLength() { + return MAX_RECORD_LENGTH; + } + /** * Decompresses the whole of the compressed RTF * stream, outputting the resulting RTF bytes. @@ -79,11 +105,14 @@ public void decompress(InputStream src, OutputStream res) throws IOException { /* int dataCRC = */ LittleEndian.readInt(src); // TODO - Handle CRC checking on the output side + IOUtils.safelyAllocateCheck(decompressedSize, MAX_RECORD_LENGTH); + LimitedOutputStream limited = new LimitedOutputStream(res, decompressedSize + MAX_PADDING_LENGTH); // Do we need to do anything? if(compressionType == UNCOMPRESSED_SIGNATURE_INT) { // Nope, nothing fancy to do - IOUtils.copy(src, res); + copyCompressedPayload(src, limited); + return; } else if(compressionType == COMPRESSED_SIGNATURE_INT) { // We need to decompress it below } else { @@ -91,7 +120,13 @@ public void decompress(InputStream src, OutputStream res) throws IOException { } // Have it processed - super.decompress(src, res); + try (InputStream bounded = BoundedInputStream.builder() + .setInputStream(src) + .setMaxCount(getCompressedSize()) + .setPropagateClose(false) + .get()) { + super.decompress(bounded, limited); + } } /** @@ -127,4 +162,48 @@ protected int populateDictionary(byte[] dict) { // Start adding new codes after the constants return preload.length; } + + private void copyCompressedPayload(InputStream src, OutputStream res) throws IOException { + long remaining = getCompressedSize(); + byte[] buffer = IOUtils.safelyAllocate(Math.min(8192L, remaining), MAX_RECORD_LENGTH); + while (remaining > 0) { + int read = src.read(buffer, 0, (int)Math.min(buffer.length, remaining)); + if (read < 0) { + throw new IOException("Not enough data to read " + getCompressedSize() + " bytes of uncompressed RTF"); + } + res.write(buffer, 0, read); + remaining -= read; + } + } + + private static final class LimitedOutputStream extends OutputStream { + private final OutputStream delegate; + private final int limit; + private int count; + + private LimitedOutputStream(OutputStream delegate, int limit) { + this.delegate = delegate; + this.limit = limit; + } + + @Override + public void write(int b) throws IOException { + checkLimit(1); + delegate.write(b); + count++; + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + checkLimit(len); + delegate.write(b, off, len); + count += len; + } + + private void checkLimit(int len) { + if (count + (long)len > limit) { + throw new RecordFormatException("Compressed RTF expands beyond declared size " + limit); + } + } + } } diff --git a/poi-scratchpad/src/test/java/org/apache/poi/hmef/TestCompressedRTF.java b/poi-scratchpad/src/test/java/org/apache/poi/hmef/TestCompressedRTF.java index 3d0b3412f9f..1f8597a428b 100644 --- a/poi-scratchpad/src/test/java/org/apache/poi/hmef/TestCompressedRTF.java +++ b/poi-scratchpad/src/test/java/org/apache/poi/hmef/TestCompressedRTF.java @@ -20,9 +20,11 @@ Licensed to the Apache Software Foundation (ASF) under one or more import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.Arrays; @@ -33,6 +35,7 @@ Licensed to the Apache Software Foundation (ASF) under one or more import org.apache.poi.hsmf.datatypes.MAPIProperty; import org.apache.poi.util.IOUtils; import org.apache.poi.util.LittleEndian; +import org.apache.poi.util.RecordFormatException; import org.apache.poi.util.StringUtil; import org.junit.jupiter.api.Test; @@ -197,4 +200,43 @@ void testFull() throws Exception { String decompStr = rtfAttr.getDataString(); assertEquals(expString, decompStr); } + + @Test + void testRejectsDeclaredDecompressedSizeOverLimit() throws Exception { + int oldLimit = CompressedRTF.getMaxRecordLength(); + try { + CompressedRTF.setMaxRecordLength(4); + + byte[] data = createRtfData(0, 5, CompressedRTF.UNCOMPRESSED_SIGNATURE_INT, new byte[0]); + CompressedRTF comp = new CompressedRTF(); + + assertThrows(RecordFormatException.class, () -> comp.decompress(new ByteArrayInputStream(data))); + } finally { + CompressedRTF.setMaxRecordLength(oldLimit); + } + } + + @Test + void testRejectsCompressedRtfExpansionBeyondDeclaredPadding() throws Exception { + ByteArrayOutputStream payload = new ByteArrayOutputStream(); + payload.write(0xff); + for (int i = 0; i < 8; i++) { + payload.write(0); + payload.write(0); + } + + byte[] data = createRtfData(payload.size(), 1, CompressedRTF.COMPRESSED_SIGNATURE_INT, payload.toByteArray()); + CompressedRTF comp = new CompressedRTF(); + + assertThrows(RecordFormatException.class, () -> comp.decompress(new ByteArrayInputStream(data))); + } + + private static byte[] createRtfData(int payloadLength, int decompressedLength, int signature, byte[] payload) { + byte[] data = new byte[16 + payload.length]; + LittleEndian.putInt(data, 0, payloadLength + 12); + LittleEndian.putInt(data, 4, decompressedLength); + LittleEndian.putInt(data, 8, signature); + System.arraycopy(payload, 0, data, 16, payload.length); + return data; + } }