srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/PLAN.md
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2026-06-30 14:52:00 +0200
committersrdusr <[email protected]>2026-06-30 14:52:00 +0200
commit384573a2dc5e3b8e2a7bdfe2ce949f2c52ba2c52 (patch)
tree13bfe97b1f5bcf90e3fad33d90b522b29f3e439d /PLAN.md
parent2ade8c807584bff0b60d6b6f278dbde29b13a5ff (diff)
downloadmitmux-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 'PLAN.md')
-rw-r--r--PLAN.md98
1 files changed, 97 insertions, 1 deletions
diff --git a/PLAN.md b/PLAN.md
index 8cffc0f..2b94b81 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -50,7 +50,8 @@ hudsucker) - same problem, worth studying even though this build is Go.
streams over one connection need per-stream request/response
boundaries, not just per-connection ones).
- CA install UX per OS (Linux/macOS/Windows trust stores)
-- Whether WebSocket interception is v1 or a later addition
+- WebSocket interception landed as a later addition, not v1 - see its
+ own section below
- Step 6 match-and-replace now covers headers and bodies. Body rules
materialize the body into memory (bounded by the same
maxCaptureBytes cap as history capture) rather than streaming it
@@ -578,3 +579,98 @@ and left vi-mode state intact, an empty rules table's right-click
correctly no-opped (no crash, no menu), and adding a real rule then
right-clicking it and clicking "disable" correctly toggled it off
(confirmed via the rendered checkmark disappearing).
+
+## WebSocket interception
+
+The last of the "known limitations" list. A WebSocket connection stops
+being one-shot HTTP request/response the instant a `101 Switching
+Protocols` comes back - it becomes a long-lived, bidirectional,
+message-framed (RFC 6455) stream instead, which forward()'s normal
+write-response-then-record flow has no way to represent. Scoped to
+HTTP/1.1 client legs only: an HTTP/2 client connection can't be
+hijacked for raw post-response access the way HTTP/1.1 can, and RFC
+8441 (WebSocket-over-HTTP/2 Extended CONNECT) is rare enough in
+practice - browsers open a dedicated HTTP/1.1 connection for a
+WebSocket even when the surrounding page is HTTP/2 - that excluding it
+isn't a real-world gap.
+
+`internal/proxy/websocket.go` holds a minimal RFC 6455 frame codec
+(`relayWSFrame`, `pumpWS`) - deliberately relay-first: it decodes a
+frame's opcode and payload for capture while writing the exact same
+raw bytes it read to the other side, unmodified. This is capture, not
+tampering, matching the rest of the codebase's "raw bytes are the
+source of truth" stance; there's no live WS message editing. One row
+per frame, not per reassembled logical message - RFC 6455 lets a
+message span several frames (opcode 0x0 continuation, FIN unset until
+the last one), which isn't reassembled here. Real-world WebSocket
+traffic is overwhelmingly single-frame; buffering an unbounded number
+of pending fragmented messages per connection to handle the rare case
+wasn't a trade worth making.
+
+`forward()` branches right after the response comes back: a 101
+matching `isWebSocketUpgradeResponse` and an HTTP/1.1-negotiated
+connection hands off to `handleWebSocketUpgrade` instead of the normal
+body-copy path - none of match-and-replace, body-rule capture, or
+`stripHopByHop`'s usual header stripping make sense for a protocol
+upgrade. handleWebSocketUpgrade hijacks the client connection, writes
+the 101 response's raw bytes through unmodified, records the upgrade
+request/response pair to history exactly like a normal exchange, then
+relays frames bidirectionally - each captured into a new `ws_messages`
+table (entry_id, direction, opcode, payload), reachable from the TUI's
+detail view via `w`.
+
+Two real bugs surfaced only by actually driving a WebSocket connection
+through a running daemon, not by reading the code - exactly the "run
+it, don't read it" pattern that's caught every prior bug like this in
+this project:
+
+- `stripHopByHop` was already stripping `Connection` and `Upgrade`
+ from the outgoing request - correct for an ordinary request per RFC
+ 7230 (they're hop-by-hop headers), catastrophic for one asking to
+ upgrade, since those two headers *are* the upgrade request. Every
+ WebSocket connection attempt silently became a 426 before this was
+ caught: the origin never saw the upgrade at all.
+ `isWebSocketUpgradeRequest` + `stripHopByHopKeepingUpgrade` fix it -
+ every other hop-by-hop header still stripped, just not these two,
+ and only for a request that's actually asking to upgrade.
+- The relay originally ended the whole handler the instant *either*
+ direction saw a close frame pass through. In practice this meant: a
+ client sends a close frame, that direction's pump relays it upstream
+ and returns, the handler tears the connection down immediately -
+ before the origin's own close-frame *reply* (which it sends after
+ receiving the client's) can be read and relayed back. The test
+ client's close handshake failed with an abrupt EOF instead of a
+ close frame. Fixed by waiting for the first direction to stop, then
+ giving the other one a bounded 5-second window to finish its own
+ close sequence before forcing both connections closed via a
+ deadline - long enough for a well-behaved peer's reply to get
+ through, bounded so a peer that never replies can't leak the
+ goroutine indefinitely.
+
+Verified live end to end against real servers, not mocks, on both
+paths a real client actually uses:
+
+- `ws://` (plain HTTP forward-proxying): a Python `websockets`-based
+ echo server, and a hand-built raw-socket test client speaking RFC
+ 6455 directly (masked client frames, unmasked server frames, the
+ 16-bit extended-length form for a 500-byte message, a binary
+ message, and a full close handshake) sent through mitmux via an
+ absolute-form `GET http://host/ HTTP/1.1` - exactly how a real
+ proxy-configured WebSocket client negotiates one. Every message
+ round-tripped correctly and the close handshake completed with both
+ directions' close frames present.
+- `wss://` (CONNECT-tunneled, TLS-intercepted): same echo server
+ behind TLS (a throwaway self-signed cert, trusted for the test via a
+ process-scoped `SSL_CERT_FILE`, never touching the real system trust
+ store), reached through mitmux's own CONNECT handling and MITM leaf
+ certificate. Confirmed the handshake, a message round-trip, and the
+ close handshake all work identically over the hijacked `*tls.Conn`
+ the CONNECT path hands back - this is the path real browsers
+ actually use for `wss://`, so this was worth checking separately
+ from the plain-HTTP path rather than assuming it'd behave the same.
+
+In both cases, `go run ./cmd/livetest` (a throwaway program, deleted
+after use - never part of the build) confirmed the captured messages
+in `ws_messages` via `ipc.Client.ListWSMessages`, matching what the
+test client actually sent and received, correctly attributed to
+direction and opcode.