diff --git a/src/main/java/core/packetproxy/encode/EncodeHTTPWebSocket.java b/src/main/java/core/packetproxy/encode/EncodeHTTPWebSocket.java index c6d50233..5355d6ac 100644 --- a/src/main/java/core/packetproxy/encode/EncodeHTTPWebSocket.java +++ b/src/main/java/core/packetproxy/encode/EncodeHTTPWebSocket.java @@ -15,12 +15,34 @@ */ package packetproxy.encode; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; import packetproxy.http.Http; import packetproxy.websocket.WebSocket; import packetproxy.websocket.WebSocketFrame; public class EncodeHTTPWebSocket extends Encoder { + /** + * Sentinel shown in History/Intercept for empty-payload WebSocket frames. + * Encode path restores this to a zero-length payload so the wire frame stays + * spec-compliant. If the user replaces this text in Intercept, the edited bytes + * are sent as the actual payload. + */ + static final byte[] EMPTY_PAYLOAD_PLACEHOLDER = "(empty WebSocket frame)".getBytes(StandardCharsets.UTF_8); + + /** + * Set when {@link #clientRequestAvailable()} replaced a zero-length payload + * with {@link #EMPTY_PAYLOAD_PLACEHOLDER}. + */ + private boolean clientEmptyPayloadFlag = false; + + /** + * Set when {@link #serverResponseAvailable()} replaced a zero-length payload + * with {@link #EMPTY_PAYLOAD_PLACEHOLDER}. + */ + private boolean serverEmptyPayloadFlag = false; + protected boolean binary_start = false; WebSocket clientWebSocket = new WebSocket(); WebSocket serverWebSocket = new WebSocket(); @@ -107,7 +129,16 @@ public byte[] passThroughServerResponse() throws Exception { public byte[] clientRequestAvailable() throws Exception { if (binary_start) { - return clientWebSocket.frameAvailable(); + byte[] payload = clientWebSocket.frameAvailable(); + // Simplex treats byte[0] from clientRequestAvailable as "no more chunks" (same + // as Encoder base). + // Map empty WebSocket payload to the placeholder so the duplex pipeline runs + // decode/intercept/send. + if (payload != null && payload.length == 0) { + clientEmptyPayloadFlag = true; + return EMPTY_PAYLOAD_PLACEHOLDER; + } + return payload; } else { return super.clientRequestAvailable(); @@ -118,7 +149,12 @@ public byte[] clientRequestAvailable() throws Exception { public byte[] serverResponseAvailable() throws Exception { if (binary_start) { - return serverWebSocket.frameAvailable(); + byte[] payload = serverWebSocket.frameAvailable(); + if (payload != null && payload.length == 0) { + serverEmptyPayloadFlag = true; + return EMPTY_PAYLOAD_PLACEHOLDER; + } + return payload; } else { return super.serverResponseAvailable(); @@ -128,7 +164,9 @@ public byte[] serverResponseAvailable() throws Exception { @Override public byte[] decodeServerResponse(byte[] input) throws Exception { if (binary_start) { - + if (input.length == 0) { + return EMPTY_PAYLOAD_PLACEHOLDER; + } return decodeWebsocketResponse(input); } else { @@ -141,7 +179,15 @@ public byte[] decodeServerResponse(byte[] input) throws Exception { public byte[] encodeServerResponse(byte[] input) throws Exception { if (binary_start) { - byte[] payload = encodeWebsocketResponse(input); + byte[] payload; + if (serverEmptyPayloadFlag) { + serverEmptyPayloadFlag = false; + payload = Arrays.equals(input, EMPTY_PAYLOAD_PLACEHOLDER) + ? new byte[0] + : encodeWebsocketResponse(input); + } else { + payload = encodeWebsocketResponse(input); + } WebSocketFrame frame = WebSocketFrame.of(serverWebSocket.lastDequeuedOpCode(), payload, false); return frame.getBytes(); } else { @@ -156,7 +202,9 @@ public byte[] encodeServerResponse(byte[] input) throws Exception { @Override public byte[] decodeClientRequest(byte[] input) throws Exception { if (binary_start) { - + if (input.length == 0) { + return EMPTY_PAYLOAD_PLACEHOLDER; + } return decodeWebsocketRequest(input); } else { @@ -169,7 +217,13 @@ public byte[] decodeClientRequest(byte[] input) throws Exception { public byte[] encodeClientRequest(byte[] input) throws Exception { if (binary_start) { - byte[] payload = encodeWebsocketRequest(input); + byte[] payload; + if (clientEmptyPayloadFlag) { + clientEmptyPayloadFlag = false; + payload = Arrays.equals(input, EMPTY_PAYLOAD_PLACEHOLDER) ? new byte[0] : encodeWebsocketRequest(input); + } else { + payload = encodeWebsocketRequest(input); + } WebSocketFrame frame = WebSocketFrame.of(clientWebSocket.lastDequeuedOpCode(), payload, true); return frame.getBytes(); } else { diff --git a/src/test/java/packetproxy/encode/EncodeHTTPWebSocketOpCodeTest.java b/src/test/java/packetproxy/encode/EncodeHTTPWebSocketOpCodeTest.java index b739476e..3969560b 100644 --- a/src/test/java/packetproxy/encode/EncodeHTTPWebSocketOpCodeTest.java +++ b/src/test/java/packetproxy/encode/EncodeHTTPWebSocketOpCodeTest.java @@ -17,6 +17,8 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import java.nio.charset.StandardCharsets; import org.junit.jupiter.api.Test; @@ -25,22 +27,51 @@ import packetproxy.websocket.WebSocketFrame; /** - * Ensures Text vs Binary opcode is preserved across decode (frameAvailable) and - * encode (getBytes). + * Tests that: 1. Text vs Binary opcode is preserved across decode/encode. 2. + * Empty-payload WebSocket frames flow through the pipeline. */ public class EncodeHTTPWebSocketOpCodeTest { - private static final byte FIN_TEXT = (byte) 0x81; - private static final byte FIN_BINARY = (byte) 0x82; + /** RFC 6455 frame byte 0: FIN (bit 7). */ + private static final int WS_FIN_BIT = 0x80; + + /** RFC 6455 frame byte 1: MASK (bit 7) for client-to-server frames. */ + private static final int WS_MASK_BIT = 0x80; + + /** + * RFC 6455 frame byte 1: bits 0–6 — payload length when that value is 0–125. + */ + private static final int WS_PAYLOAD_LEN_7BIT_MASK = 0x7F; + + private static byte finFirstByte(OpCode opcode) { + return (byte) (WS_FIN_BIT | (opcode.code & 0x0F)); + } + + private static byte unmaskedPayloadLenByte(int payloadLength) { + return (byte) (payloadLength & WS_PAYLOAD_LEN_7BIT_MASK); + } + + private static byte maskedPayloadLenByte(int payloadLength) { + return (byte) (WS_MASK_BIT | (payloadLength & WS_PAYLOAD_LEN_7BIT_MASK)); + } + + private static int payloadLen7Bits(byte secondByte) { + return secondByte & WS_PAYLOAD_LEN_7BIT_MASK; + } /** Unmasked FIN+Text frame, payload "hello". */ private static byte[] textFrameHello() { - return new byte[]{FIN_TEXT, 0x05, 'h', 'e', 'l', 'l', 'o'}; + byte[] payload = "hello".getBytes(StandardCharsets.UTF_8); + byte[] frame = new byte[2 + payload.length]; + frame[0] = finFirstByte(OpCode.Text); + frame[1] = unmaskedPayloadLenByte(payload.length); + System.arraycopy(payload, 0, frame, 2, payload.length); + return frame; } /** Unmasked FIN+Binary frame, single zero byte payload. */ private static byte[] binaryFrameOneByte() { - return new byte[]{FIN_BINARY, 0x01, 0x00}; + return new byte[]{finFirstByte(OpCode.Binary), unmaskedPayloadLenByte(1), 0x00}; } @Test @@ -66,7 +97,7 @@ public void encodeClientRequestPreservesTextOpcode() throws Exception { encoder.clientWebSocket.frameArrived(textFrameHello()); byte[] payload = encoder.clientRequestAvailable(); byte[] wire = encoder.encodeClientRequest(payload); - assertEquals(FIN_TEXT, wire[0]); + assertEquals(finFirstByte(OpCode.Text), wire[0]); } @Test @@ -76,7 +107,7 @@ public void encodeClientRequestPreservesBinaryOpcode() throws Exception { encoder.clientWebSocket.frameArrived(binaryFrameOneByte()); byte[] payload = encoder.clientRequestAvailable(); byte[] wire = encoder.encodeClientRequest(payload); - assertEquals(FIN_BINARY, wire[0]); + assertEquals(finFirstByte(OpCode.Binary), wire[0]); } @Test @@ -86,7 +117,7 @@ public void encodeServerResponsePreservesTextOpcode() throws Exception { encoder.serverWebSocket.frameArrived(textFrameHello()); byte[] payload = encoder.serverResponseAvailable(); byte[] wire = encoder.encodeServerResponse(payload); - assertEquals(FIN_TEXT, wire[0]); + assertEquals(finFirstByte(OpCode.Text), wire[0]); } @Test @@ -100,6 +131,120 @@ public void parseThenSerializeRoundTripKeepsOpcode() throws Exception { assertArrayEquals(binaryFrameOneByte(), WebSocketFrame.of(bin.getOpcode(), bin.getPayload(), false).getBytes()); } + /** Unmasked FIN+Text frame, zero-length payload. */ + private static byte[] textFrameEmptyPayload() { + return new byte[]{finFirstByte(OpCode.Text), unmaskedPayloadLenByte(0)}; + } + + /** Unmasked FIN+Binary frame, zero-length payload. */ + private static byte[] binaryFrameEmptyPayload() { + return new byte[]{finFirstByte(OpCode.Binary), unmaskedPayloadLenByte(0)}; + } + + @Test + public void emptyPayloadTextFrameQueuesForDecode() throws Exception { + WebSocket ws = new WebSocket(); + ws.frameArrived(textFrameEmptyPayload()); + assertArrayEquals(new byte[0], ws.passThroughFrame()); + assertArrayEquals(new byte[0], ws.frameAvailable()); + } + + @Test + public void emptyPayloadBinaryFrameQueuesForDecode() throws Exception { + WebSocket ws = new WebSocket(); + ws.frameArrived(binaryFrameEmptyPayload()); + assertArrayEquals(new byte[0], ws.passThroughFrame()); + assertArrayEquals(new byte[0], ws.frameAvailable()); + } + + @Test + public void decodeClientRequestReturnsPlaceholderForEmptyPayload() throws Exception { + TestEncoder encoder = new TestEncoder(); + encoder.setBinaryStart(true); + byte[] decoded = encoder.decodeClientRequest(new byte[0]); + assertArrayEquals(EncodeHTTPWebSocket.EMPTY_PAYLOAD_PLACEHOLDER, decoded); + } + + @Test + public void decodeServerResponseReturnsPlaceholderForEmptyPayload() throws Exception { + TestEncoder encoder = new TestEncoder(); + encoder.setBinaryStart(true); + byte[] decoded = encoder.decodeServerResponse(new byte[0]); + assertArrayEquals(EncodeHTTPWebSocket.EMPTY_PAYLOAD_PLACEHOLDER, decoded); + } + + @Test + public void emptyPayloadClientRequestRoundTrip() throws Exception { + TestEncoder encoder = new TestEncoder(); + encoder.setBinaryStart(true); + encoder.clientWebSocket.frameArrived(textFrameEmptyPayload()); + encoder.clientWebSocket.passThroughFrame(); + byte[] payload = encoder.clientRequestAvailable(); + assertNotNull(payload); + assertArrayEquals(EncodeHTTPWebSocket.EMPTY_PAYLOAD_PLACEHOLDER, payload); + byte[] decoded = encoder.decodeClientRequest(payload); + byte[] wire = encoder.encodeClientRequest(decoded); + // lastDequeuedOpCode preserves Text from the original frame + assertEquals(finFirstByte(OpCode.Text), wire[0]); + assertEquals(0, payloadLen7Bits(wire[1])); + } + + @Test + public void emptyPayloadServerResponseRoundTrip() throws Exception { + TestEncoder encoder = new TestEncoder(); + encoder.setBinaryStart(true); + encoder.serverWebSocket.frameArrived(binaryFrameEmptyPayload()); + encoder.serverWebSocket.passThroughFrame(); + byte[] payload = encoder.serverResponseAvailable(); + assertNotNull(payload); + assertArrayEquals(EncodeHTTPWebSocket.EMPTY_PAYLOAD_PLACEHOLDER, payload); + byte[] decoded = encoder.decodeServerResponse(payload); + byte[] wire = encoder.encodeServerResponse(decoded); + assertArrayEquals(binaryFrameEmptyPayload(), wire); + } + + @Test + public void editedPlaceholderClientRequestSendsNewContent() throws Exception { + TestEncoder encoder = new TestEncoder(); + encoder.setBinaryStart(true); + encoder.clientWebSocket.frameArrived(textFrameEmptyPayload()); + encoder.clientWebSocket.passThroughFrame(); + encoder.clientRequestAvailable(); + byte[] userEdited = "hello".getBytes(StandardCharsets.UTF_8); + byte[] wire = encoder.encodeClientRequest(userEdited); + // lastDequeuedOpCode preserves Text from the original frame + assertEquals(finFirstByte(OpCode.Text), wire[0]); + assertEquals(maskedPayloadLenByte(userEdited.length), wire[1]); + } + + /** + * If the literal placeholder bytes are sent as payload without having come from + * an empty frame, encode must not collapse them to a zero-length payload. + */ + @Test + public void placeholderPayloadWithoutEmptyFrameFlagIsNotCollapsedToEmpty() throws Exception { + TestEncoder encoder = new TestEncoder(); + encoder.setBinaryStart(true); + encoder.clientWebSocket.frameArrived(textFrameHello()); + encoder.clientWebSocket.passThroughFrame(); + encoder.clientRequestAvailable(); + byte[] wire = encoder.encodeClientRequest(EncodeHTTPWebSocket.EMPTY_PAYLOAD_PLACEHOLDER); + assertEquals(finFirstByte(OpCode.Text), wire[0]); + assertNotEquals(0, payloadLen7Bits(wire[1])); + } + + @Test + public void placeholderServerPayloadWithoutEmptyFrameFlagIsNotCollapsedToEmpty() throws Exception { + TestEncoder encoder = new TestEncoder(); + encoder.setBinaryStart(true); + encoder.serverWebSocket.frameArrived(textFrameHello()); + encoder.serverWebSocket.passThroughFrame(); + encoder.serverResponseAvailable(); + byte[] wire = encoder.encodeServerResponse(EncodeHTTPWebSocket.EMPTY_PAYLOAD_PLACEHOLDER); + assertEquals(finFirstByte(OpCode.Text), wire[0]); + assertEquals(EncodeHTTPWebSocket.EMPTY_PAYLOAD_PLACEHOLDER.length, payloadLen7Bits(wire[1])); + } + private static final class TestEncoder extends EncodeHTTPWebSocket { TestEncoder() throws Exception {