This is an automated email from the ASF dual-hosted git repository.
markt-asf pushed a commit to branch 11.0.x
in repository https://gitbox.apache.org/repos/asf/tomcat.git
The following commit(s) were added to refs/heads/11.0.x by this push:
new 4fef25fe2a Fix per-message-deflate with non-final blocks that have
BFINAL set
4fef25fe2a is described below
commit 4fef25fe2ab7509e697af093280b9daadb515615
Author: Mark Thomas <[email protected]>
AuthorDate: Tue Sep 8 19:32:47 2026 +0100
Fix per-message-deflate with non-final blocks that have BFINAL set
---
.../apache/tomcat/websocket/PerMessageDeflate.java | 69 +++++-
.../tomcat/websocket/TestPerMessageDeflate.java | 253 +++++++++++++++++++--
webapps/docs/changelog.xml | 5 +
3 files changed, 308 insertions(+), 19 deletions(-)
diff --git a/java/org/apache/tomcat/websocket/PerMessageDeflate.java
b/java/org/apache/tomcat/websocket/PerMessageDeflate.java
index 10ffa1dbbc..502b5d0b91 100644
--- a/java/org/apache/tomcat/websocket/PerMessageDeflate.java
+++ b/java/org/apache/tomcat/websocket/PerMessageDeflate.java
@@ -76,6 +76,16 @@ public class PerMessageDeflate implements Transformation {
private volatile boolean skipDecompression = false;
private volatile boolean eomBytesInserted = false;
private volatile boolean eomOverflowWritten = false;
+ /*
+ * Offset and length, within readBuffer's backing array, of the compressed
bytes most recently passed to
+ * inflater.setInput(). Used to work out where the unconsumed tail starts
if inflater.finished() becomes true before
+ * all of those bytes have been consumed (see getMoreData()). Both fields
must be kept in sync with whatever the
+ * most recent setInput() call actually used - lastInputOffset is not
always readBuffer.arrayOffset(): after the
+ * first such recovery, the next input segment starts wherever the
previous one left off, not at the start of
+ * readBuffer's backing array.
+ */
+ private volatile int lastInputOffset;
+ private volatile int lastInputLength;
private volatile ByteBuffer writeBuffer =
ByteBuffer.allocate(Constants.DEFAULT_BUFFER_SIZE);
private volatile boolean firstCompressedFrameWritten = false;
// Flag to track if a message is completely empty
@@ -238,6 +248,8 @@ public class PerMessageDeflate implements Transformation {
if (inflater.needsInput() && !eomBytesInserted) {
readBuffer.clear();
TransformationResult nextResult = next.getMoreData(opCode,
fin, (rsv ^ RSV_BITMASK), readBuffer);
+ lastInputOffset = readBuffer.arrayOffset();
+ lastInputLength = readBuffer.position();
inflater.setInput(readBuffer.array(),
readBuffer.arrayOffset(), readBuffer.position());
if (dest.hasRemaining()) {
if (TransformationResult.UNDERFLOW.equals(nextResult)) {
@@ -267,7 +279,37 @@ public class PerMessageDeflate implements Transformation {
sm.getString("perMessageDeflate.next.ise",
next.getClass().getName()));
}
} else if (written == 0) {
- return endFrame(fin);
+ if (!eomBytesInserted && inflater.finished() &&
inflater.getRemaining() > 0) {
+ /*
+ * RFC 7692 section 7.2.1 permits an endpoint to compress
a single message using multiple DEFLATE
+ * blocks with any mix of BFINAL values, including a block
with BFINAL=1 that is not the last block
+ * of the message.
+ *
+ * If inflater is finished without EOM bytes being
inserted and with data still to process this
+ * indicates there is at least one more block to process.
Inflater has no API to continue once it
+ * has finished. From this point on, it silently ignores
any further input. The only way to process
+ * the remaining, still-unconsumed bytes belonging to this
same message is to reset() the Inflater
+ * (clearing the finished state) and feed it just the
unconsumed tail.
+ */
+ int remaining = inflater.getRemaining();
+ /*
+ * The unconsumed tail starts wherever the *current* input
segment started, not necessarily at
+ * readBuffer.arrayOffset(): if this is the second (or
later) recovery for the same message, the
+ * current segment already starts partway into readBuffer.
+ */
+ int newOffset = lastInputOffset + lastInputLength -
remaining;
+ try {
+ inflater.reset();
+ } catch (NullPointerException e) {
+ throw new
IOException(sm.getString("perMessageDeflate.alreadyClosed"), e);
+ }
+ inflater.setInput(readBuffer.array(), newOffset,
remaining);
+ lastInputOffset = newOffset;
+ lastInputLength = remaining;
+ // Continue decompression loop
+ } else {
+ return endFrame(fin);
+ }
}
}
@@ -313,11 +355,26 @@ public class PerMessageDeflate implements Transformation {
private TransformationResult endFrame(boolean fin) throws IOException {
eomBytesInserted = false;
eomOverflowWritten = false;
- if (fin && (isServer && !clientContextTakeover || !isServer &&
!serverContextTakeover)) {
- try {
- inflater.reset();
- } catch (NullPointerException e) {
- throw new
IOException(sm.getString("perMessageDeflate.alreadyClosed"), e);
+ if (fin) {
+ boolean contextTakeover = isServer ? clientContextTakeover :
serverContextTakeover;
+ /*
+ * If the message's final block was itself an independently
BFINAL=1 terminated block (see the recovery
+ * in getMoreData()), the EOM_BYTES appended to complete the
message per RFC 7692 section 7.2.2 were fed
+ * to an already-finished Inflater and were never consumed:
inflater.finished() stays true with those 4
+ * bytes still reported by getRemaining(). Left in that state, the
next message would compute its first
+ * recovery offset from this stale, unrelated leftover count,
which can go negative. There is no way to
+ * continue decompressing past a finished Inflater in place, so it
has to be reset here too - even though
+ * context takeover is enabled - at the cost of losing the window
for the *next* message in this,
+ * otherwise rare, case.
+ */
+ if (!contextTakeover || inflater.finished()) {
+ try {
+ inflater.reset();
+ } catch (NullPointerException e) {
+ throw new
IOException(sm.getString("perMessageDeflate.alreadyClosed"), e);
+ }
+ lastInputOffset = 0;
+ lastInputLength = 0;
}
}
return TransformationResult.END_OF_FRAME;
diff --git a/test/org/apache/tomcat/websocket/TestPerMessageDeflate.java
b/test/org/apache/tomcat/websocket/TestPerMessageDeflate.java
index 74a77d112e..93a7ede4a3 100644
--- a/test/org/apache/tomcat/websocket/TestPerMessageDeflate.java
+++ b/test/org/apache/tomcat/websocket/TestPerMessageDeflate.java
@@ -16,6 +16,7 @@
*/
package org.apache.tomcat.websocket;
+import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
@@ -23,6 +24,7 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
+import java.util.zip.Deflater;
import jakarta.websocket.Extension;
import jakarta.websocket.Extension.Parameter;
@@ -47,16 +49,14 @@ public class TestPerMessageDeflate {
perMessageDeflate.setNext(new TesterTransformation());
ByteBuffer bb1 = ByteBuffer.wrap("A".getBytes(StandardCharsets.UTF_8));
- MessagePart mp1 =
- new MessagePart(true, 0, Constants.OPCODE_TEXT, bb1, null,
null, false, Long.MAX_VALUE);
+ MessagePart mp1 = new MessagePart(true, 0, Constants.OPCODE_TEXT, bb1,
null, null, false, Long.MAX_VALUE);
List<MessagePart> uncompressedParts1 = new ArrayList<>();
uncompressedParts1.add(mp1);
perMessageDeflate.sendMessagePart(uncompressedParts1);
ByteBuffer bb2 = ByteBuffer.wrap("".getBytes(StandardCharsets.UTF_8));
- MessagePart mp2 =
- new MessagePart(true, 0, Constants.OPCODE_TEXT, bb2, null,
null, false, Long.MAX_VALUE);
+ MessagePart mp2 = new MessagePart(true, 0, Constants.OPCODE_TEXT, bb2,
null, null, false, Long.MAX_VALUE);
List<MessagePart> uncompressedParts2 = new ArrayList<>();
uncompressedParts2.add(mp2);
@@ -83,8 +83,7 @@ public class TestPerMessageDeflate {
ByteBuffer bb = ByteBuffer.wrap(data);
long writeTimeoutExpiry = System.currentTimeMillis() + 10000;
- MessagePart mp =
- new MessagePart(true, 0, Constants.OPCODE_BINARY, bb, null,
null, true, writeTimeoutExpiry);
+ MessagePart mp = new MessagePart(true, 0, Constants.OPCODE_BINARY, bb,
null, null, true, writeTimeoutExpiry);
List<MessagePart> uncompressedParts = new ArrayList<>();
uncompressedParts.add(mp);
@@ -125,8 +124,8 @@ public class TestPerMessageDeflate {
byte[] data = new byte[size];
Arrays.fill(data, (byte) 0x80);
List<MessagePart> uncompressedParts = new ArrayList<>();
- uncompressedParts.add(new MessagePart(true, 0,
Constants.OPCODE_BINARY,
- ByteBuffer.wrap(data), null, null, false, Long.MAX_VALUE));
+ uncompressedParts.add(new MessagePart(true, 0,
Constants.OPCODE_BINARY, ByteBuffer.wrap(data), null, null,
+ false, Long.MAX_VALUE));
MessagePart compressedPart =
perMessageDeflateTx.sendMessagePart(uncompressedParts).get(0);
// Decompress the way WsFrameBase.processDataBinary does: fill an
8192
@@ -172,18 +171,17 @@ public class TestPerMessageDeflate {
// First message part
byte[] data = new byte[1024];
ByteBuffer bb = ByteBuffer.wrap(data);
- MessagePart mp1 =
- new MessagePart(true, 0, Constants.OPCODE_BINARY, bb, null,
null, false, Long.MAX_VALUE);
+ MessagePart mp1 = new MessagePart(true, 0, Constants.OPCODE_BINARY,
bb, null, null, false, Long.MAX_VALUE);
uncompressedParts.add(mp1);
// Flush message (replicates result of calling flushBatch()
- MessagePart mp2 = new MessagePart(true, 0,
Constants.INTERNAL_OPCODE_FLUSH, null, null, null, false,
- Long.MAX_VALUE);
+ MessagePart mp2 =
+ new MessagePart(true, 0, Constants.INTERNAL_OPCODE_FLUSH,
null, null, null, false, Long.MAX_VALUE);
uncompressedParts.add(mp2);
List<MessagePart> compressedParts =
perMessageDeflateTx.sendMessagePart(uncompressedParts);
- Assert.assertEquals(2, compressedParts.size());
+ Assert.assertEquals(2, compressedParts.size());
// Check the first compressed part
MessagePart compressedPart1 = compressedParts.get(0);
@@ -205,6 +203,181 @@ public class TestPerMessageDeflate {
}
+ /*
+ * RFC 7692 section 7.2.1 explicitly permits an endpoint to compress a
single message using multiple DEFLATE blocks,
+ * of any type, with any mix of BFINAL values - including multiple,
independently terminated (BFINAL=1) DEFLATE
+ * blocks/streams concatenated together. This is not something
PerMessageDeflate's own compressor produces (it
+ * always uses a single, sync-flush terminated stream), so the payload has
to be constructed by hand to reproduce
+ * it. A compliant receiver must still recover the full, correctly
ordered, decompressed content of the message.
+ */
+ @Test
+ public void testMultipleFinalDeflateBlocksInOneMessage() throws
IOException {
+
+ byte[] part1 = "Hello, this is the FIRST part of the
message.".getBytes(StandardCharsets.UTF_8);
+ byte[] part2 = "And this is the SECOND, independently terminated
part.".getBytes(StandardCharsets.UTF_8);
+
+ // Two separate raw DEFLATE streams, each terminated with BFINAL=1,
concatenated together.
+ byte[] compressedPayload = concat(rawDeflateFinished(part1),
rawDeflateFinished(part2));
+
+ List<Parameter> parameters = Collections.emptyList();
+ List<List<Parameter>> preferences = new ArrayList<>();
+ preferences.add(parameters);
+
+ PerMessageDeflate perMessageDeflateRx =
PerMessageDeflate.build(preferences, true);
+ perMessageDeflateRx.setNext(new
TesterTransformation(ByteBuffer.wrap(compressedPayload)));
+
+ // RSV1 (the permessage-deflate compression bit) set, RSV2/RSV3 clear.
+ int rsv = 0b100;
+
+ ByteArrayOutputStream received = new ByteArrayOutputStream();
+ ByteBuffer buf = ByteBuffer.allocate(8192);
+ TransformationResult tr;
+ do {
+ buf.clear();
+ tr = perMessageDeflateRx.getMoreData(Constants.OPCODE_BINARY,
true, rsv, buf);
+ received.write(buf.array(), 0, buf.position());
+ } while (tr == TransformationResult.OVERFLOW);
+
+ Assert.assertEquals(TransformationResult.END_OF_FRAME, tr);
+
+ byte[] expected = concat(part1, part2);
+
+ Assert.assertArrayEquals("Expected the concatenation of both DEFLATE
blocks' decompressed content, got: " +
+ received.size() + " bytes: [" + received.toString("UTF-8") +
"]", expected, received.toByteArray());
+ }
+
+
+ /*
+ * As testMultipleFinalDeflateBlocksInOneMessage, but with three
independently terminated (BFINAL=1) DEFLATE blocks
+ * concatenated together. This requires two recoveries in getMoreData()
back to back, with no intervening
+ * readBuffer.clear()-based fetch between them - which is what exposes an
offset bug that a single-recovery
+ * (two-block) message cannot: computing the second recovery's offset from
readBuffer.arrayOffset() (fixed) rather
+ * than from where the first recovery's input segment actually started.
+ */
+ @Test
+ public void testThreeFinalDeflateBlocksInOneMessage() throws IOException {
+
+ byte[] part1 = "First part.".getBytes(StandardCharsets.UTF_8);
+ byte[] part2 = "Second, somewhat longer
part.".getBytes(StandardCharsets.UTF_8);
+ byte[] part3 = "Third and final
part.".getBytes(StandardCharsets.UTF_8);
+
+ byte[] compressedPayload =
+ concat(concat(rawDeflateFinished(part1),
rawDeflateFinished(part2)), rawDeflateFinished(part3));
+
+ List<Parameter> parameters = Collections.emptyList();
+ List<List<Parameter>> preferences = new ArrayList<>();
+ preferences.add(parameters);
+
+ PerMessageDeflate perMessageDeflateRx =
PerMessageDeflate.build(preferences, true);
+ perMessageDeflateRx.setNext(new
TesterTransformation(ByteBuffer.wrap(compressedPayload)));
+
+ int rsv = 0b100;
+
+ ByteArrayOutputStream received = new ByteArrayOutputStream();
+ ByteBuffer buf = ByteBuffer.allocate(8192);
+ TransformationResult tr;
+ do {
+ buf.clear();
+ tr = perMessageDeflateRx.getMoreData(Constants.OPCODE_BINARY,
true, rsv, buf);
+ received.write(buf.array(), 0, buf.position());
+ } while (tr == TransformationResult.OVERFLOW);
+
+ Assert.assertEquals(TransformationResult.END_OF_FRAME, tr);
+
+ byte[] expected = concat(concat(part1, part2), part3);
+
+ Assert.assertArrayEquals(
+ "Expected the concatenation of all three DEFLATE blocks'
decompressed content, " + "got: " +
+ received.size() + " bytes: [" +
received.toString("UTF-8") + "]",
+ expected, received.toByteArray());
+ }
+
+
+ /*
+ * A message whose real content is a single, exactly-fitting,
independently BFINAL=1 terminated block (nothing
+ * else) finishes cleanly via the normal needsInput()==true path and, as
part of that, has the RFC 7692 section
+ * 7.2.2 EOM_BYTES fed to an already-finished Inflater - which silently
ignores them, leaving finished()==true
+ * with those 4 bytes stuck in getRemaining(). With context takeover
enabled, endFrame() must not leave the
+ * Inflater in that state: otherwise the next message's recovery logic
computes its first offset from this
+ * stale, unrelated leftover count.
+ */
+ @Test
+ public void testMessageEndingInCleanBfinalBlockDoesNotPoisonNextMessage()
throws IOException {
+ byte[] message1 = "First message, a single clean BFINAL
block.".getBytes(StandardCharsets.UTF_8);
+ byte[] compressed1 = rawDeflateFinished(message1);
+
+ List<Parameter> parameters = Collections.emptyList();
+ List<List<Parameter>> preferences = new ArrayList<>();
+ preferences.add(parameters);
+
+ /*
+ * Context takeover is enabled by default (no *_no_context_takeover
parameter) - the same PerMessageDeflate
+ * instance, and therefore the same Inflater, must be reused across
messages, exactly as it would be for a
+ * real connection. setNext() on PerMessageDeflate delegates to the
existing next's setNext() once next is
+ * already set, so a single mutable source (rather than two separate
TesterTransformation instances) is used
+ * to supply both messages' bytes in turn.
+ */
+ PerMessageDeflate perMessageDeflateRx =
PerMessageDeflate.build(preferences, true);
+ MutableTesterTransformation source = new
MutableTesterTransformation(ByteBuffer.wrap(compressed1));
+ perMessageDeflateRx.setNext(source);
+ int rsv = 0b100;
+
+ ByteArrayOutputStream received1 = new ByteArrayOutputStream();
+ ByteBuffer buf = ByteBuffer.allocate(8192);
+ TransformationResult tr;
+ do {
+ buf.clear();
+ tr = perMessageDeflateRx.getMoreData(Constants.OPCODE_BINARY,
true, rsv, buf);
+ received1.write(buf.array(), 0, buf.position());
+ } while (tr == TransformationResult.OVERFLOW);
+ Assert.assertEquals(TransformationResult.END_OF_FRAME, tr);
+ Assert.assertArrayEquals(message1, received1.toByteArray());
+
+ // A completely separate, ordinary message on the same
connection/Transformation instance.
+ byte[] message2 = "Second message on the same
connection.".getBytes(StandardCharsets.UTF_8);
+ byte[] compressed2 = rawDeflateFinished(message2);
+ source.data = ByteBuffer.wrap(compressed2);
+ source.delivered = false;
+
+ ByteArrayOutputStream received2 = new ByteArrayOutputStream();
+ do {
+ buf.clear();
+ tr = perMessageDeflateRx.getMoreData(Constants.OPCODE_BINARY,
true, rsv, buf);
+ received2.write(buf.array(), 0, buf.position());
+ } while (tr == TransformationResult.OVERFLOW);
+ Assert.assertEquals(TransformationResult.END_OF_FRAME, tr);
+ Assert.assertArrayEquals("Second message must decompress correctly;
the first message's clean BFINAL "
+ + "ending must not poison the shared Inflater's state",
message2, received2.toByteArray());
+ }
+
+
+ private static byte[] rawDeflateFinished(byte[] data) {
+ @SuppressWarnings("resource") // False positive
+ Deflater deflater = new Deflater(Deflater.DEFAULT_COMPRESSION, true);
+ try {
+ deflater.setInput(data);
+ deflater.finish();
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ byte[] buf = new byte[4096];
+ while (!deflater.finished()) {
+ int n = deflater.deflate(buf);
+ baos.write(buf, 0, n);
+ }
+ return baos.toByteArray();
+ } finally {
+ deflater.end();
+ }
+ }
+
+
+ private static byte[] concat(byte[] a, byte[] b) {
+ byte[] result = new byte[a.length + b.length];
+ System.arraycopy(a, 0, result, 0, a.length);
+ System.arraycopy(b, 0, result, a.length, b.length);
+ return result;
+ }
+
+
/*
* RFC 7692 section 5.1 requires a permessage-deflate offer that contains
an invalid extension parameter to be
* declined so the handshake continues without compression. The offer must
not fail the handshake.
@@ -281,4 +454,58 @@ public class TestPerMessageDeflate {
public void close() {
}
}
+
+
+ /*
+ * Like TesterTransformation, but the source ByteBuffer can be swapped out
between messages, to exercise reuse of
+ * a single PerMessageDeflate instance (and therefore its Inflater) across
multiple messages, as happens on a
+ * real connection with context takeover enabled.
+ */
+ private static class MutableTesterTransformation implements Transformation
{
+
+ ByteBuffer data;
+ boolean delivered;
+
+ MutableTesterTransformation(ByteBuffer data) {
+ this.data = data;
+ }
+
+ @Override
+ public boolean validateRsvBits(int i) {
+ return false;
+ }
+
+ @Override
+ public boolean validateRsv(int rsv, byte opCode) {
+ return false;
+ }
+
+ @Override
+ public void setNext(Transformation t) {
+ }
+
+ @Override
+ public List<MessagePart> sendMessagePart(List<MessagePart>
messageParts) {
+ return messageParts;
+ }
+
+ @Override
+ public TransformationResult getMoreData(byte opCode, boolean fin, int
rsv, ByteBuffer dest) {
+ if (delivered) {
+ return TransformationResult.END_OF_FRAME;
+ }
+ dest.put(data);
+ delivered = true;
+ return TransformationResult.END_OF_FRAME;
+ }
+
+ @Override
+ public Extension getExtensionResponse() {
+ return null;
+ }
+
+ @Override
+ public void close() {
+ }
+ }
}
diff --git a/webapps/docs/changelog.xml b/webapps/docs/changelog.xml
index f7cf4ceea7..fd28d64963 100644
--- a/webapps/docs/changelog.xml
+++ b/webapps/docs/changelog.xml
@@ -330,6 +330,11 @@
<code>{</code> and <code>}</code> characters from being mapped to
WebSocket end points. (markt)
</fix>
+ <fix>
+ Fix handling of WebSocket messages with compressed payloads using
+ per-message-deflate that have one or more non-final blocks where the
+ <code>BFINAL</code> bit is set. (markt)
+ </fix>
</changelog>
</subsection>
<subsection name="Web applications">
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]