diff options
| author | srdusr <[email protected]> | 2026-06-30 14:52:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2026-06-30 14:52:00 +0200 |
| commit | 384573a2dc5e3b8e2a7bdfe2ce949f2c52ba2c52 (patch) | |
| tree | 13bfe97b1f5bcf90e3fad33d90b522b29f3e439d /internal/proxy/websocket_test.go | |
| parent | 2ade8c807584bff0b60d6b6f278dbde29b13a5ff (diff) | |
| download | mitmux-384573a2dc5e3b8e2a7bdfe2ce949f2c52ba2c52.tar.gz mitmux-384573a2dc5e3b8e2a7bdfe2ce949f2c52ba2c52.zip | |
WebSocket interception
The last "known limitation": a ws://wss:// connection stops being
one-shot request/response the instant its 101 Switching Protocols
lands, and forward()'s normal write-response-then-record flow has no
way to represent that. Scoped to HTTP/1.1 client legs (HTTP/2 can't be
hijacked for raw post-response access the way HTTP/1.1 can, and
browsers open a dedicated HTTP/1.1 connection for WebSocket regardless
of the surrounding page's protocol, so this isn't a real-world gap).
internal/proxy/websocket.go decodes each RFC 6455 frame's opcode and
payload for capture while relaying the exact same raw bytes it read
unmodified - this is capture, not tampering, matching the rest of the
codebase's raw-bytes-as-source-of-truth stance. One row per frame, not
per reassembled message (fragmentation is rare in real-world
WebSocket traffic; not worth buffering an unbounded number of pending
fragments to handle it). forward() branches on a matching 101 into
handleWebSocketUpgrade, which hijacks the client connection, relays
the handshake response raw, records the upgrade request/response to
history normally, then relays frames bidirectionally into a new
ws_messages table - reachable from the TUI's detail view via `w`.
Found and fixed two real bugs by actually driving a WebSocket
connection through a running daemon, not by reading the code:
stripHopByHop was deleting Connection/Upgrade from every outgoing
request (correct for an ordinary request per RFC 7230, catastrophic
for one asking to upgrade - every WebSocket attempt silently became a
426); and the relay tore the whole connection down the instant either
side saw a close frame, before the peer's own close-frame reply could
be relayed back, producing an abrupt EOF instead of a clean close.
Verified live end to end on both paths a real client uses: ws://
(plain HTTP forward-proxying) against a Python websockets echo
server, and wss:// (CONNECT-tunneled, TLS-intercepted) against the
same server behind TLS - text, binary, and extended-length frames,
plus a full close handshake with both directions' close frames
present, confirmed via the actual bytes captured in ws_messages.
Diffstat (limited to 'internal/proxy/websocket_test.go')
| -rw-r--r-- | internal/proxy/websocket_test.go | 159 |
1 files changed, 159 insertions, 0 deletions
diff --git a/internal/proxy/websocket_test.go b/internal/proxy/websocket_test.go new file mode 100644 index 0000000..8256567 --- /dev/null +++ b/internal/proxy/websocket_test.go @@ -0,0 +1,159 @@ +package proxy + +import ( + "bytes" + "testing" +) + +// maskedFrame builds a masked (client-to-server style) RFC 6455 frame +// for opcode carrying payload, mask applied per spec (XOR with a 4-byte +// key repeated across the payload). +func maskedFrame(t *testing.T, opcode byte, payload []byte, key [4]byte) []byte { + t.Helper() + var buf bytes.Buffer + buf.WriteByte(0x80 | opcode) // FIN=1 + if len(payload) > 125 { + t.Fatalf("test helper only supports short payloads") + } + buf.WriteByte(0x80 | byte(len(payload))) // MASK=1 + buf.Write(key[:]) + masked := make([]byte, len(payload)) + for i, b := range payload { + masked[i] = b ^ key[i%4] + } + buf.Write(masked) + return buf.Bytes() +} + +// unmaskedFrame builds an unmasked (server-to-client style) frame. +func unmaskedFrame(t *testing.T, opcode byte, payload []byte) []byte { + t.Helper() + var buf bytes.Buffer + buf.WriteByte(0x80 | opcode) + if len(payload) > 125 { + t.Fatalf("test helper only supports short payloads") + } + buf.WriteByte(byte(len(payload))) // MASK=0 + buf.Write(payload) + return buf.Bytes() +} + +func TestRelayWSFrameMaskedRoundTrips(t *testing.T) { + src := maskedFrame(t, wsOpText, []byte("hello"), [4]byte{1, 2, 3, 4}) + var dst bytes.Buffer + + opcode, payload, err := relayWSFrame(bytes.NewReader(src), &dst) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opcode != wsOpText { + t.Errorf("opcode = %d, want %d", opcode, wsOpText) + } + if string(payload) != "hello" { + t.Errorf("decoded payload = %q, want %q", payload, "hello") + } + // The relayed bytes must be byte-for-byte identical to the input - + // capture decodes for display, it never re-encodes what's on the + // wire. + if !bytes.Equal(dst.Bytes(), src) { + t.Errorf("relayed bytes = %x, want exactly %x (byte-exact passthrough)", dst.Bytes(), src) + } +} + +func TestRelayWSFrameUnmaskedRoundTrips(t *testing.T) { + src := unmaskedFrame(t, wsOpBinary, []byte{0xde, 0xad, 0xbe, 0xef}) + var dst bytes.Buffer + + opcode, payload, err := relayWSFrame(bytes.NewReader(src), &dst) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opcode != wsOpBinary { + t.Errorf("opcode = %d, want %d", opcode, wsOpBinary) + } + if !bytes.Equal(payload, []byte{0xde, 0xad, 0xbe, 0xef}) { + t.Errorf("decoded payload = %x, want deadbeef", payload) + } + if !bytes.Equal(dst.Bytes(), src) { + t.Error("relayed bytes don't match input exactly") + } +} + +func TestRelayWSFrameExtended16BitLength(t *testing.T) { + payload := bytes.Repeat([]byte("a"), 200) // over 125, forces the 126 extended-length form + var buf bytes.Buffer + buf.WriteByte(0x80 | wsOpBinary) + buf.WriteByte(126) + buf.Write([]byte{0x00, 0xc8}) // 200 in 16-bit big-endian + buf.Write(payload) + + var dst bytes.Buffer + opcode, got, err := relayWSFrame(bytes.NewReader(buf.Bytes()), &dst) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opcode != wsOpBinary { + t.Errorf("opcode = %d, want %d", opcode, wsOpBinary) + } + if !bytes.Equal(got, payload) { + t.Errorf("payload length = %d, want %d", len(got), len(payload)) + } + if !bytes.Equal(dst.Bytes(), buf.Bytes()) { + t.Error("relayed bytes don't match input exactly") + } +} + +func TestRelayWSFrameOverCapRejected(t *testing.T) { + var buf bytes.Buffer + buf.WriteByte(0x80 | wsOpBinary) + buf.WriteByte(127) + lenBytes := make([]byte, 8) + big := uint64(maxCaptureBytes + 1) + for i := 7; i >= 0; i-- { + lenBytes[i] = byte(big) + big >>= 8 + } + buf.Write(lenBytes) + // Deliberately no payload bytes written - relayWSFrame must reject + // based on the length field alone, before trying to read a body this + // large. + + var dst bytes.Buffer + _, _, err := relayWSFrame(bytes.NewReader(buf.Bytes()), &dst) + if err == nil { + t.Fatal("expected an error for a frame over the capture limit") + } +} + +func TestPumpWSCapturesUntilClose(t *testing.T) { + var src bytes.Buffer + src.Write(unmaskedFrame(t, wsOpText, []byte("one"))) + src.Write(unmaskedFrame(t, wsOpText, []byte("two"))) + src.Write(unmaskedFrame(t, wsOpClose, nil)) + // A frame after close must never be reached. + src.Write(unmaskedFrame(t, wsOpText, []byte("unreachable"))) + + var dst bytes.Buffer + var captured [][]byte + pumpWS(&src, &dst, func(opcode byte, payload []byte) { + captured = append(captured, append([]byte(nil), payload...)) + }) + + if len(captured) != 3 { + t.Fatalf("captured %d frames, want 3 (two messages + close)", len(captured)) + } + if string(captured[0]) != "one" || string(captured[1]) != "two" { + t.Errorf("captured = %q, %q, want \"one\", \"two\"", captured[0], captured[1]) + } +} + +func TestPumpWSStopsOnReadError(t *testing.T) { + // Truncated frame: header claims a payload that never arrives. + src := bytes.NewReader([]byte{0x81, 0x05, 'h', 'i'}) + var dst bytes.Buffer + called := false + pumpWS(src, &dst, func(byte, []byte) { called = true }) + if called { + t.Error("capture should never run for a frame that fails to read fully") + } +} |