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.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.go')
| -rw-r--r-- | internal/proxy/websocket.go | 159 |
1 files changed, 159 insertions, 0 deletions
diff --git a/internal/proxy/websocket.go b/internal/proxy/websocket.go new file mode 100644 index 0000000..10a934c --- /dev/null +++ b/internal/proxy/websocket.go @@ -0,0 +1,159 @@ +// WebSocket interception: after a client's Upgrade: websocket request +// gets a matching 101 Switching Protocols response back from the origin +// (see forward's WS branch), the connection stops being HTTP request/ +// response and becomes a long-lived, bidirectional, message-framed +// stream (RFC 6455) instead. mitmux relays every frame byte-for-byte +// unmodified in both directions - this is capture, not tampering - while +// decoding each one's payload for display, recorded to the ws_messages +// table tagged to the upgrade request's own history entry. +// +// Deliberately one row per frame, not per logical message: RFC 6455 +// lets a single message span several frames (opcode 0x0 continuation, +// FIN unset until the last one), which mitmux does not reassemble. +// Real-world WebSocket traffic - JSON events, chat messages, game state +// - is overwhelmingly single-frame; reassembly would need buffering an +// unbounded number of pending fragmented messages per connection for a +// case that's rare in practice, which isn't a trade worth making here. +package proxy + +import ( + "encoding/binary" + "fmt" + "io" + "net/http" + "strings" +) + +// WebSocket opcodes (RFC 6455 section 5.2). +const ( + wsOpContinuation = 0x0 + wsOpText = 0x1 + wsOpBinary = 0x2 + wsOpClose = 0x8 + wsOpPing = 0x9 + wsOpPong = 0xa +) + +// isWebSocketUpgradeRequest reports whether r is asking to upgrade to a +// WebSocket connection (Connection: Upgrade plus Upgrade: websocket). +// forward() uses this to keep those two headers off stripHopByHop's list +// for this one request - RFC 7230 correctly treats Connection/Upgrade as +// hop-by-hop for a normal request, but stripping them here would delete +// the very signal the origin needs to recognize the upgrade at all, +// turning every WebSocket connection attempt into a silent 426. +func isWebSocketUpgradeRequest(r *http.Request) bool { + return headerHasToken(r.Header, "Connection", "upgrade") && + strings.EqualFold(r.Header.Get("Upgrade"), "websocket") +} + +// isWebSocketUpgradeResponse reports whether resp is a successful +// WebSocket upgrade (101 Switching Protocols, with Connection: Upgrade +// and Upgrade: websocket) - checking the response rather than the +// request it answers, since a 101 only ever comes back from an origin +// that accepted the upgrade, and that's the one thing forward() actually +// needs to know before handing the connection off. +func isWebSocketUpgradeResponse(resp *http.Response) bool { + return resp.StatusCode == http.StatusSwitchingProtocols && + headerHasToken(resp.Header, "Connection", "upgrade") && + strings.EqualFold(resp.Header.Get("Upgrade"), "websocket") +} + +func headerHasToken(h http.Header, name, token string) bool { + for _, v := range h.Values(name) { + for _, part := range strings.Split(v, ",") { + if strings.EqualFold(strings.TrimSpace(part), token) { + return true + } + } + } + return false +} + +// relayWSFrame reads exactly one RFC 6455 frame from src, writes the +// same raw bytes to dst unmodified, and returns the frame's opcode and +// decoded (unmasked) payload for capture. Masking is direction- +// dependent - client-to-server frames are always masked, server-to- +// client frames never are - but relayed bytes are whatever was actually +// read, so this works correctly regardless of which direction it's +// called for. +func relayWSFrame(src io.Reader, dst io.Writer) (opcode byte, payload []byte, err error) { + hdr := make([]byte, 2) + if _, err = io.ReadFull(src, hdr); err != nil { + return 0, nil, err + } + opcode = hdr[0] & 0x0f + masked := hdr[1]&0x80 != 0 + length := uint64(hdr[1] & 0x7f) + + raw := append([]byte(nil), hdr...) + + switch length { + case 126: + ext := make([]byte, 2) + if _, err = io.ReadFull(src, ext); err != nil { + return 0, nil, err + } + raw = append(raw, ext...) + length = uint64(binary.BigEndian.Uint16(ext)) + case 127: + ext := make([]byte, 8) + if _, err = io.ReadFull(src, ext); err != nil { + return 0, nil, err + } + raw = append(raw, ext...) + length = binary.BigEndian.Uint64(ext) + } + + var maskKey [4]byte + if masked { + if _, err = io.ReadFull(src, maskKey[:]); err != nil { + return 0, nil, err + } + raw = append(raw, maskKey[:]...) + } + + // Bounded the same way request/response body capture is (see + // maxCaptureBytes) - a length field mitmux doesn't control shouldn't + // be able to force an unbounded read/allocation. Relaying (not just + // capturing) is refused too: a frame this large is already well + // outside normal WebSocket usage, and guessing at a partial relay + // would corrupt the stream's framing for whichever side reads next. + if length > maxCaptureBytes { + return 0, nil, fmt.Errorf("websocket frame too large (%d bytes, over the %d limit)", length, maxCaptureBytes) + } + + body := make([]byte, length) + if _, err = io.ReadFull(src, body); err != nil { + return 0, nil, err + } + raw = append(raw, body...) + + if _, err = dst.Write(raw); err != nil { + return 0, nil, err + } + + if !masked { + return opcode, body, nil + } + payload = make([]byte, length) + for i := range payload { + payload[i] = body[i] ^ maskKey[i%4] + } + return opcode, payload, nil +} + +// pumpWS relays frames from src to dst until one fails to read/write or +// a close frame (opcode 0x8) passes through, calling capture with each +// frame's opcode and decoded payload as it goes. +func pumpWS(src io.Reader, dst io.Writer, capture func(opcode byte, payload []byte)) { + for { + opcode, payload, err := relayWSFrame(src, dst) + if err != nil { + return + } + capture(opcode, payload) + if opcode == wsOpClose { + return + } + } +} |