diff options
Diffstat (limited to 'PLAN.md')
| -rw-r--r-- | PLAN.md | 98 |
1 files changed, 97 insertions, 1 deletions
@@ -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. |