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 /cmd | |
| 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 'cmd')
| -rw-r--r-- | cmd/mitmux/main.go | 187 |
1 files changed, 185 insertions, 2 deletions
diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go index 6e07069..afb345b 100644 --- a/cmd/mitmux/main.go +++ b/cmd/mitmux/main.go @@ -116,6 +116,7 @@ const ( viewDecoder viewScope viewClientCerts + viewWebSocket viewHelp ) @@ -236,6 +237,15 @@ type model struct { clientCertKeyPath textinput.Model clientCertIsRegex bool + // WebSocket messages captured for one history entry's upgraded + // connection (see proxy.go's handleWebSocketUpgrade) - reached from + // the detail view, not its own top-level list. + wsTable table.Model + wsRows []store.WSMessage + wsEntryID int64 + wsShowingDetail bool + wsDetailViewport viewport.Model + intruderScheme string intruderHost string intruderTemplate viTextarea @@ -389,6 +399,16 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary, socketPath string) ccKeyPathIn := textinput.New() ccKeyPathIn.Placeholder = "path to PEM private key file" + wsCols := []table.Column{ + {Title: "#", Width: 4}, + {Title: "Dir", Width: 3}, + {Title: "Op", Width: 6}, + {Title: "Size", Width: 8}, + {Title: "Preview", Width: 50}, + } + wsTbl := table.New(table.WithColumns(wsCols), table.WithFocused(true)) + wsTbl.SetStyles(st) + itmpl := newViTextarea() itmpl.ta.Placeholder = "raw request bytes - wrap positions to fuzz in § markers, e.g. /users/§123§" itmpl.ta.ShowLineNumbers = false @@ -441,6 +461,7 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary, socketPath string) clientCertPattern: ccPatternIn, clientCertCertPath: ccCertPathIn, clientCertKeyPath: ccKeyPathIn, + wsTable: wsTbl, ruleName: nameIn, ruleMatch: matchIn, ruleReplace: replaceIn, @@ -638,6 +659,19 @@ func (m *model) loadDetail(id int64, dest string) tea.Cmd { } } +type wsMessagesLoadedMsg struct { + entryID int64 + messages []store.WSMessage + err error +} + +func (m *model) loadWSMessages(entryID int64) tea.Cmd { + return func() tea.Msg { + msgs, err := m.client.ListWSMessages(entryID) + return wsMessagesLoadedMsg{entryID: entryID, messages: msgs, err: err} + } +} + // markOrCompare implements 'c': the first press on an entry marks it as // the comparison base (no fetch yet - cheap, no round trip until there's // actually something to compare). A second press on a *different* entry @@ -1154,6 +1188,9 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.searchInput.Width = msg.Width - 2 m.viewport = viewport.New(msg.Width, h-5) m.compareViewport = viewport.New(msg.Width, h-5) + m.wsTable.SetWidth(msg.Width) + m.wsTable.SetHeight(h - 5) + m.wsDetailViewport = viewport.New(msg.Width, h-5) decInHeight := (h - 8) / 2 m.decoderInput.SetWidth(msg.Width) @@ -1298,6 +1335,23 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.viewport.GotoTop() return m, nil + case wsMessagesLoadedMsg: + if msg.err != nil { + m.statusMsg = "websocket messages error: " + msg.err.Error() + return m, nil + } + if len(msg.messages) == 0 { + m.statusMsg = "no websocket messages captured for this entry" + return m, nil + } + m.wsRows = msg.messages + m.wsEntryID = msg.entryID + m.wsShowingDetail = false + setTableRows(&m.wsTable, wsRowsFor(m.wsRows)) + m.mode = viewWebSocket + m.statusMsg = "" + return m, nil + case compareLoadedMsg: if msg.err != nil { m.statusMsg = "compare error: " + msg.err.Error() @@ -1695,6 +1749,12 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.detail != nil { return m, m.markOrCompare(m.detail.ID) } + case "w": + if m.detail != nil { + m.statusMsg = "loading websocket messages..." + return m, m.loadWSMessages(m.detail.ID) + } + return m, nil case "tab": if m.activeTab == tabRequest { m.activeTab = tabResponse @@ -1978,6 +2038,41 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.clientCertTable, cmd = m.clientCertTable.Update(msg) return m, cmd + case viewWebSocket: + if m.wsShowingDetail { + switch msg.String() { + case "q", "esc": + m.wsShowingDetail = false + return m, nil + case "ctrl+c": + return m, tea.Quit + } + var cmd tea.Cmd + m.wsDetailViewport, cmd = m.wsDetailViewport.Update(msg) + return m, cmd + } + switch msg.String() { + case "q", "esc": + m.mode = viewDetail + return m, nil + case "ctrl+c": + return m, tea.Quit + case "?": + m.prevMode = viewWebSocket + m.mode = viewHelp + return m, nil + case "enter": + if row := m.wsTable.Cursor(); row >= 0 && row < len(m.wsRows) { + m.wsShowingDetail = true + m.wsDetailViewport.SetContent(wsMessageDetail(m.wsRows[row])) + m.wsDetailViewport.GotoTop() + } + return m, nil + } + var cmd tea.Cmd + m.wsTable, cmd = m.wsTable.Update(msg) + return m, cmd + case viewIntruder: // Editing a grep pattern is a modal overlay on top of the // normal template/payloads/results panes, same pattern as @@ -2235,6 +2330,12 @@ func (m *model) View() string { } else { body = m.clientCertView() } + case viewWebSocket: + if m.wsShowingDetail { + body = m.wsDetailView() + } else { + body = m.wsView() + } case viewIntruder: body = m.intruderView() case viewCompare: @@ -2264,7 +2365,7 @@ func (m *model) statusBar() string { view := map[viewMode]string{ viewList: "history", viewDetail: "detail", viewRepeater: "repeater", viewRules: "rules", viewIntruder: "intruder", viewCompare: "comparer", viewDecoder: "decoder", - viewScope: "scope", viewClientCerts: "client certs", + viewScope: "scope", viewClientCerts: "client certs", viewWebSocket: "websocket", }[m.mode] return statusBarStyle.Render(fmt.Sprintf(" mitmux · proxy %s%s · %s ", proxy, count, view)) } @@ -2323,8 +2424,16 @@ func (m *model) helpView() string { "c mark/compare (same as history list)", "r / i open in Repeater / Intruder", "e export this entry - .txt (raw request+response) or .sh/.curl (curl command)", + "w view captured WebSocket messages (only for an upgraded connection)", "esc / q back to history", ) + section("WebSocket messages", + "One row per captured frame (not per reassembled message - see", + "PLAN.md) for the entry's upgraded connection.", + "↑/↓ or j/k navigate (also g/G, ctrl+u/d)", + "enter view this frame's full decoded payload", + "esc / q back (from payload view: back to the message list)", + ) section("Comparer", "tab switch request/response diff", "↑/↓ or j/k scroll (also g/G, ctrl+u/d - same as history list)", @@ -2477,7 +2586,7 @@ func (m *model) detailView() string { b.WriteString(statusStyle.Render(sanitizeLine(m.statusMsg))) b.WriteString("\n") } - b.WriteString(helpStyle.Render("tab switch · p pretty-print · c compare · r repeater · i intruder · e export · esc back · ? help · q quit")) + b.WriteString(helpStyle.Render("tab switch · p pretty-print · c compare · r repeater · i intruder · e export · w websocket · esc back · ? help · q quit")) return b.String() } @@ -2733,6 +2842,80 @@ func clientCertRowsFor(cs []clientcert.Cert) []table.Row { return rows } +func (m *model) wsView() string { + var b strings.Builder + title := fmt.Sprintf(" websocket messages (%d) - entry #%d ", len(m.wsRows), m.wsEntryID) + b.WriteString(titleStyle.Render(title)) + b.WriteString("\n") + b.WriteString(m.wsTable.View()) + b.WriteString("\n") + if m.statusMsg != "" { + b.WriteString(statusStyle.Render(sanitizeLine(m.statusMsg))) + b.WriteString("\n") + } + b.WriteString(helpStyle.Render("enter view payload · esc back · q quit")) + return b.String() +} + +func (m *model) wsDetailView() string { + var b strings.Builder + b.WriteString(titleStyle.Render(" websocket message ")) + b.WriteString("\n") + b.WriteString(m.wsDetailViewport.View()) + b.WriteString("\n") + b.WriteString(helpStyle.Render("↑/↓ scroll · esc back · q quit")) + return b.String() +} + +func wsOpcodeName(opcode int) string { + switch opcode { + case 0x1: + return "text" + case 0x2: + return "binary" + case 0x8: + return "close" + case 0x9: + return "ping" + case 0xa: + return "pong" + default: + return fmt.Sprintf("0x%x", opcode) + } +} + +func wsRowsFor(msgs []store.WSMessage) []table.Row { + rows := make([]table.Row, len(msgs)) + for i, m := range msgs { + dir := "->" + if m.Direction == "server_to_client" { + dir = "<-" + } + rows[i] = table.Row{ + fmt.Sprintf("%d", i+1), + dir, + wsOpcodeName(m.Opcode), + humanBytes(len(m.Payload)), + sanitizeLine(string(m.Payload)), + } + } + return rows +} + +// wsMessageDetail is the full, sanitized content shown when viewing one +// captured frame's payload - sanitizeBlock because, same as request/ +// response bodies elsewhere, this is attacker- or origin-controlled data +// reaching the operator's real terminal, not just the display width +// truncation the table preview gets away with. +func wsMessageDetail(m store.WSMessage) string { + dir := "client -> server" + if m.Direction == "server_to_client" { + dir = "server -> client" + } + return fmt.Sprintf("%s · opcode: %s · %s\n\n%s", + dir, wsOpcodeName(m.Opcode), humanBytes(len(m.Payload)), sanitizeBlock(string(m.Payload))) +} + // nextAttackMode cycles Sniper -> BatteringRam -> Pitchfork -> ClusterBomb // -> Sniper. func nextAttackMode(mode proxy.AttackMode) proxy.AttackMode { |