Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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;


/**
Expand Down Expand Up @@ -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;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why this length as opposed to some other value?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mainly wanted a limit that was large enough to avoid rejecting legitimate larger RTF payloads, while still enforcing a reasonable upper bound for malformed or inconsistent size declarations.

The 50MB value was meant as a conservative default rather than a format-defined maximum. Happy to align it with an existing POI-wide limit pattern, or reduce/remove the configurable default if you’d prefer.

/**
* 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;

Expand All @@ -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.
Expand All @@ -79,19 +105,28 @@ 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;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't use return like this - also copyCompressedPayload has its own limit checks, quite untidy to do this

} else if(compressionType == COMPRESSED_SIGNATURE_INT) {
// We need to decompress it below
} else {
throw new IllegalArgumentException("Invalid compression signature " + compressionType);
}

// Have it processed
super.decompress(src, res);
try (InputStream bounded = BoundedInputStream.builder()
.setInputStream(src)
.setMaxCount(getCompressedSize())
.setPropagateClose(false)
.get()) {
super.decompress(bounded, limited);
}
}

/**
Expand Down Expand Up @@ -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 {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -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;
}
}