From 384573a2dc5e3b8e2a7bdfe2ce949f2c52ba2c52 Mon Sep 17 00:00:00 2001 From: srdusr <99972264+srdusr@users.noreply.github.com> Date: Tue, 30 Jun 2026 14:52:00 +0200 Subject: 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. --- PLAN.md | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 97 insertions(+), 1 deletion(-) (limited to 'PLAN.md') 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. -- cgit v1.2.3