diff options
| -rw-r--r-- | cmd/mitmux/compare.go | 12 | ||||
| -rw-r--r-- | cmd/mitmux/decoder.go | 8 | ||||
| -rw-r--r-- | cmd/mitmux/main.go | 89 | ||||
| -rw-r--r-- | cmd/mitmux/pretty.go | 5 | ||||
| -rw-r--r-- | cmd/mitmux/sanitize.go | 50 | ||||
| -rw-r--r-- | cmd/mitmux/sanitize_test.go | 47 |
6 files changed, 177 insertions, 34 deletions
diff --git a/cmd/mitmux/compare.go b/cmd/mitmux/compare.go index 34bb684..84a1930 100644 --- a/cmd/mitmux/compare.go +++ b/cmd/mitmux/compare.go @@ -71,7 +71,11 @@ func (m *model) compareContent() string { } else { aText, bText = string(m.compareA.ResponseRaw), string(m.compareB.ResponseRaw) } - return unifiedDiff(fmt.Sprintf("#%d", m.compareA.ID), fmt.Sprintf("#%d", m.compareB.ID), aText, bText) + // Sanitized before diffing (display-only, same as the CRLF + // normalization already happening inside unifiedDiff) - otherwise a + // captured entry with control-character/ANSI injection would corrupt + // the comparer's own rendering just by being diffed. See sanitize.go. + return unifiedDiff(fmt.Sprintf("#%d", m.compareA.ID), fmt.Sprintf("#%d", m.compareB.ID), sanitizeBlock(aText), sanitizeBlock(bText)) } func (m *model) compareView() string { @@ -81,8 +85,8 @@ func (m *model) compareView() string { return b.String() } title := fmt.Sprintf(" comparer - #%d (%s %s%s -> %d) vs #%d (%s %s%s -> %d) ", - m.compareA.ID, m.compareA.Method, m.compareA.Host, m.compareA.Path, m.compareA.StatusCode, - m.compareB.ID, m.compareB.Method, m.compareB.Host, m.compareB.Path, m.compareB.StatusCode) + m.compareA.ID, sanitizeLine(m.compareA.Method), sanitizeLine(m.compareA.Host), sanitizeLine(m.compareA.Path), m.compareA.StatusCode, + m.compareB.ID, sanitizeLine(m.compareB.Method), sanitizeLine(m.compareB.Host), sanitizeLine(m.compareB.Path), m.compareB.StatusCode) b.WriteString(titleStyle.Render(title)) b.WriteString("\n") @@ -97,7 +101,7 @@ func (m *model) compareView() string { b.WriteString(m.compareViewport.View()) b.WriteString("\n") if m.statusMsg != "" { - b.WriteString(statusStyle.Render(m.statusMsg)) + b.WriteString(statusStyle.Render(sanitizeLine(m.statusMsg))) b.WriteString("\n") } b.WriteString(helpStyle.Render("tab switch request/response · ↑/↓ scroll · esc back · q quit")) diff --git a/cmd/mitmux/decoder.go b/cmd/mitmux/decoder.go index aebb647..69416a5 100644 --- a/cmd/mitmux/decoder.go +++ b/cmd/mitmux/decoder.go @@ -118,7 +118,11 @@ func decodeBase64Lenient(s string) ([]byte, error) { } func (m *model) decoderContent() string { - return applyDecoderOp(m.decoderOp, m.decoderInput.Value()) + // Sanitized (see sanitize.go): a decode operation on pasted-in + // content (e.g. hex or Base64 copied from a captured response) can + // legitimately produce raw control bytes, which would otherwise + // reach the terminal via this output pane. + return sanitizeBlock(applyDecoderOp(m.decoderOp, m.decoderInput.Value())) } func (m *model) decoderView() string { @@ -142,7 +146,7 @@ func (m *model) decoderView() string { b.WriteString(viModeLabel(&m.decoderInput)) b.WriteString("\n") if m.statusMsg != "" { - b.WriteString(statusStyle.Render(m.statusMsg)) + b.WriteString(statusStyle.Render(sanitizeLine(m.statusMsg))) b.WriteString("\n") } b.WriteString(helpStyle.Render("i to edit (vi keys) · tab/shift+tab cycle transform · esc back · ? help · ctrl+c quit")) diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go index 1d6d3c5..75f4410 100644 --- a/cmd/mitmux/main.go +++ b/cmd/mitmux/main.go @@ -481,7 +481,15 @@ func (m *model) enterRepeater(d *ipc.EntryDetail) { reqArea: newViTextarea(), } t.reqArea.ta.ShowLineNumbers = false - t.reqArea.SetValue(strings.ReplaceAll(string(d.RequestRaw), "\r\n", "\n")) + // sanitizeBlock both normalizes CRLF->LF (the textarea only + // understands LF, same trade-off as sendRepeat's restore-on-send) + // and strips control-character/ANSI injection from the seeded text + // (see sanitize.go) - a captured request containing raw escape bytes + // would otherwise corrupt the editor's own rendering just by being + // opened. Narrow, deliberate trade-off: resending such a request + // unmodified via ctrl+r sends the sanitized text, not the original + // control bytes: type them back in directly to send them verbatim. + t.reqArea.SetValue(sanitizeBlock(string(d.RequestRaw))) t.reqArea.Focus() t.focus = focusRequest m.sizeRepeaterTab(t) @@ -497,11 +505,13 @@ func (m *model) enterRepeater(d *ipc.EntryDetail) { func (m *model) enterIntruder(d *ipc.EntryDetail) { m.intruderScheme = d.Scheme m.intruderHost = d.Host - m.intruderTemplate.SetValue(strings.ReplaceAll(string(d.RequestRaw), "\r\n", "\n")) + // See enterRepeater's identical seeding for why sanitizeBlock (not + // just CRLF normalization) is used here. + m.intruderTemplate.SetValue(sanitizeBlock(string(d.RequestRaw))) m.intruderTemplate.Focus() m.intruderPayloads.Blur() m.intruderRows = nil - m.intruderResults.SetRows(nil) + setTableRows(&m.intruderResults, nil) m.intruderFocus = focusTemplate m.intruderRunning = false m.intruderCount = 0 @@ -705,7 +715,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } m.entries = msg.entries - m.table.SetRows(rowsFor(m.entries)) + setTableRows(&m.table, rowsFor(m.entries)) return m, nil case newEntryMsg: @@ -725,7 +735,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.waitForEntry } m.entries = append([]store.Summary{msg.entry}, m.entries...) - m.table.SetRows(rowsFor(m.entries)) + setTableRows(&m.table, rowsFor(m.entries)) return m, m.waitForEntry case statusLoadedMsg: @@ -801,7 +811,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } m.ruleRows = msg.rules - m.rulesTable.SetRows(rulesRowsFor(m.ruleRows)) + setTableRows(&m.rulesTable, rulesRowsFor(m.ruleRows)) return m, nil case ruleWriteDoneMsg: @@ -834,7 +844,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.intruderCount++ m.intruderRows = append(m.intruderRows, msg.result) - m.intruderResults.SetRows(intrudeRowsFor(m.intruderRows)) + setTableRows(&m.intruderResults, intrudeRowsFor(m.intruderRows)) return m, m.waitForIntrudeResult case tea.KeyMsg: @@ -885,7 +895,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "f": if row := m.table.Cursor(); row >= 0 && row < len(m.entries) { m.entries[row].Flagged = !m.entries[row].Flagged - m.table.SetRows(rowsFor(m.entries)) + setTableRows(&m.table, rowsFor(m.entries)) return m, m.setFlagged(m.entries[row].ID, m.entries[row].Flagged) } case "c": @@ -1497,7 +1507,7 @@ func (m *model) listView() string { b.WriteString(m.table.View()) b.WriteString("\n") if m.statusMsg != "" { - b.WriteString(statusStyle.Render(m.statusMsg)) + b.WriteString(statusStyle.Render(sanitizeLine(m.statusMsg))) b.WriteString("\n") } help := "enter view · r/i/c/d tools · f flag · / search · m rules · ? help · q quit" @@ -1518,7 +1528,7 @@ func (m *model) detailView() string { if m.detail.Flagged { flagMark = "★ " } - title := fmt.Sprintf(" %s#%d %s %s%s -> %d ", flagMark, m.detail.ID, m.detail.Method, m.detail.Host, m.detail.Path, m.detail.StatusCode) + title := fmt.Sprintf(" %s#%d %s %s%s -> %d ", flagMark, m.detail.ID, sanitizeLine(m.detail.Method), sanitizeLine(m.detail.Host), sanitizeLine(m.detail.Path), m.detail.StatusCode) b.WriteString(titleStyle.Render(title)) b.WriteString("\n") @@ -1553,7 +1563,7 @@ func (m *model) repeaterView() string { if len(m.repeaterTabs) > 1 { for i, rt := range m.repeaterTabs { - label := fmt.Sprintf(" %d:%s ", i+1, rt.host) + label := fmt.Sprintf(" %d:%s ", i+1, sanitizeLine(rt.host)) if i == m.repeaterIndex { b.WriteString(tabActive.Render(label)) } else { @@ -1563,7 +1573,7 @@ func (m *model) repeaterView() string { b.WriteString("\n") } - title := fmt.Sprintf(" repeater - %s://%s ", t.scheme, t.host) + title := fmt.Sprintf(" repeater - %s://%s ", sanitizeLine(t.scheme), sanitizeLine(t.host)) b.WriteString(titleStyle.Render(title)) b.WriteString("\n") @@ -1589,7 +1599,7 @@ func (m *model) repeaterView() string { b.WriteString("\n") } if m.statusMsg != "" { - b.WriteString(statusStyle.Render(m.statusMsg)) + b.WriteString(statusStyle.Render(sanitizeLine(m.statusMsg))) b.WriteString("\n") } b.WriteString(helpStyle.Render("i to edit (vi keys) · ctrl+r send · tab switch pane · ]/[ next/prev tab · ctrl+w close tab · esc back · ? help · ctrl+c quit")) @@ -1613,7 +1623,7 @@ func (m *model) rulesView() string { b.WriteString(m.rulesTable.View()) b.WriteString("\n") if m.statusMsg != "" { - b.WriteString(statusStyle.Render(m.statusMsg)) + b.WriteString(statusStyle.Render(sanitizeLine(m.statusMsg))) b.WriteString("\n") } b.WriteString(helpStyle.Render("a add · enter/e edit · d delete · space toggle · esc back · q quit")) @@ -1652,7 +1662,7 @@ func (m *model) ruleFormView() string { b.WriteString(label(fieldRegex, regexText) + "\n\n") if m.statusMsg != "" { - b.WriteString(statusStyle.Render(m.statusMsg)) + b.WriteString(statusStyle.Render(sanitizeLine(m.statusMsg))) b.WriteString("\n") } b.WriteString(helpStyle.Render("tab/shift+tab move · ctrl+s save · esc cancel · ctrl+c quit")) @@ -1677,9 +1687,9 @@ func rulesRowsFor(rs []rules.Rule) []table.Row { func (m *model) intruderView() string { var b strings.Builder - title := fmt.Sprintf(" intruder - %s://%s ", m.intruderScheme, m.intruderHost) + title := fmt.Sprintf(" intruder - %s://%s ", sanitizeLine(m.intruderScheme), sanitizeLine(m.intruderHost)) if m.intruderRunning { - title = fmt.Sprintf(" intruder - %s://%s (running, %d sent) ", m.intruderScheme, m.intruderHost, m.intruderCount) + title = fmt.Sprintf(" intruder - %s://%s (running, %d sent) ", sanitizeLine(m.intruderScheme), sanitizeLine(m.intruderHost), m.intruderCount) } b.WriteString(titleStyle.Render(title)) b.WriteString("\n") @@ -1734,7 +1744,7 @@ func (m *model) intruderView() string { b.WriteString("\n") } if m.statusMsg != "" { - b.WriteString(statusStyle.Render(m.statusMsg)) + b.WriteString(statusStyle.Render(sanitizeLine(m.statusMsg))) b.WriteString("\n") } if m.grepEditing != 0 { @@ -1758,13 +1768,16 @@ func intrudeRowsFor(rs []ipc.IntrudeResultMsg) []table.Row { } rows[i] = table.Row{ fmt.Sprintf("%d", r.Position), - r.Payload, + sanitizeLine(r.Payload), status, humanBytes(r.RespSize), r.Duration.Round(time.Millisecond).String(), match, - r.GrepExtract, - r.Error, + // GrepExtract is pulled directly out of the (attacker- + // controlled) response body via a user regex - sanitize it + // same as everything else derived from captured traffic. + sanitizeLine(r.GrepExtract), + sanitizeLine(r.Error), } } return rows @@ -1790,6 +1803,10 @@ func (m *model) detailContent() string { return detailBody(m.detail, m.activeTab) } +// detailBody renders raw request/response bytes as display text. +// Sanitized against control-character/ANSI injection (see sanitize.go) +// - this is what's shown, never what's stored or resent, so it stays +// display-only like every other transform here. func detailBody(d *ipc.EntryDetail, tab detailTab) string { if d == nil { return "" @@ -1798,15 +1815,15 @@ func detailBody(d *ipc.EntryDetail, tab detailTab) string { if len(d.RequestRaw) == 0 { return "(empty)" } - return string(d.RequestRaw) + return sanitizeBlock(string(d.RequestRaw)) } if len(d.ResponseRaw) == 0 { if d.Error != "" { - return "(no response - " + d.Error + ")" + return "(no response - " + sanitizeLine(d.Error) + ")" } return "(empty)" } - return string(d.ResponseRaw) + return sanitizeBlock(string(d.ResponseRaw)) } func rowsFor(entries []store.Summary) []table.Row { @@ -1824,9 +1841,9 @@ func rowsFor(entries []store.Summary) []table.Row { rows[i] = table.Row{ flag, fmt.Sprintf("%d", e.ID), - e.Method, - e.Host, - e.Path, + sanitizeLine(e.Method), + sanitizeLine(e.Host), + sanitizeLine(e.Path), status, size, e.Duration.Round(time.Millisecond).String(), @@ -1835,6 +1852,24 @@ func rowsFor(entries []store.Summary) []table.Row { return rows } +// setTableRows replaces t's rows and re-clamps the cursor afterward. +// bubbles/table's own SetRows only clamps the cursor's upper bound +// (cursor > len(rows)-1) - on an empty table the cursor sits at -1, and +// going from 0 rows to N rows never re-clamps that lower bound, so the +// cursor stays at -1 until the next navigation keypress. Every "row >= +// 0 && row < len(...)" guard in this file (enter/r/i/f/c, everywhere) +// then silently no-ops on a table that looks populated but has an +// invalid selection - confirmed live: a live-captured entry arriving +// while the history list was still empty left every action on it inert +// until an arrow key was pressed first. SetCursor re-clamps both bounds +// (see bubbles' clamp(n, 0, len(rows)-1)), so calling it after every +// SetRows is a cheap, always-safe normalization - a no-op when the +// cursor was already valid. +func setTableRows(t *table.Model, rows []table.Row) { + t.SetRows(rows) + t.SetCursor(t.Cursor()) +} + func humanBytes(n int) string { if n < 1024 { return fmt.Sprintf("%dB", n) diff --git a/cmd/mitmux/pretty.go b/cmd/mitmux/pretty.go index ede6809..f6da6ad 100644 --- a/cmd/mitmux/pretty.go +++ b/cmd/mitmux/pretty.go @@ -57,7 +57,10 @@ func prettyResponse(raw []byte) (formatted string, ok bool) { writeHeadersSorted(&out, resp.Header, "Transfer-Encoding", "Content-Encoding", "Content-Length") out.WriteString("\r\n") out.Write(pretty.Bytes()) - return out.String(), true + // Status and header values are server-controlled and not JSON-escaped + // like the body is - sanitize the whole rendering against control- + // character/ANSI injection before it's ever displayed (see sanitize.go). + return sanitizeBlock(out.String()), true } func writeHeadersSorted(out *bytes.Buffer, h http.Header, omit ...string) { diff --git a/cmd/mitmux/sanitize.go b/cmd/mitmux/sanitize.go new file mode 100644 index 0000000..8549d5c --- /dev/null +++ b/cmd/mitmux/sanitize.go @@ -0,0 +1,50 @@ +package main + +import "strings" + +// sanitizeControl replaces ASCII control bytes with a literal "." before +// captured content is displayed. mitmux exists specifically to MITM +// hostile servers, and every rendered string in this TUI - table cells, +// detail/repeater/comparer text, decoder output - is written straight to +// stdout via lipgloss with no escaping of its own. A raw ANSI/OSC escape +// sequence in a Host, Path, header, or body (or extracted from one via +// Intruder's grep-extract) would reach the operator's actual terminal +// and could rewrite its title, move the cursor, or clear the screen - +// confirmed live: a Host containing an OSC title-change sequence changed +// the real tmux pane title; a Path containing a clear-screen sequence +// corrupted the TUI's own rendering. This is a display-only transform, +// same as the CRLF/pretty-printing helpers elsewhere - it never touches +// stored bytes or what Repeater/Intruder actually send, only what gets +// written to the terminal. +// +// keepLF preserves '\n' and '\t' for multi-line raw-text views where +// structure matters (request/response text, diffs, decoder output); +// single-line contexts (table cells, title bars) should pass +// keepLF=false. '\r' is always dropped rather than passed through raw - +// CRLF is reconstructed at editor boundaries elsewhere, and a bare '\r' +// has no legitimate display purpose but does move the cursor if written +// as-is. +func sanitizeControl(s string, keepLF bool) string { + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + switch { + case (r == '\n' || r == '\t') && keepLF: + b.WriteRune(r) + case r == '\r': + case r < 0x20 || r == 0x7f: + b.WriteByte('.') + default: + b.WriteRune(r) + } + } + return b.String() +} + +// sanitizeLine sanitizes a single-line display value (table cell, title, +// label). +func sanitizeLine(s string) string { return sanitizeControl(s, false) } + +// sanitizeBlock sanitizes a multi-line display value (raw request/ +// response text, diffs, decoder output), preserving line breaks. +func sanitizeBlock(s string) string { return sanitizeControl(s, true) } diff --git a/cmd/mitmux/sanitize_test.go b/cmd/mitmux/sanitize_test.go new file mode 100644 index 0000000..a487413 --- /dev/null +++ b/cmd/mitmux/sanitize_test.go @@ -0,0 +1,47 @@ +package main + +import "testing" + +func TestSanitizeLine(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"plain text unchanged", "example.com/path", "example.com/path"}, + {"osc title change stripped", "evil\x1b]0;pwned\x07.com", "evil.]0;pwned..com"}, + {"csi clear screen stripped", "\x1b[2J\x1b[Hpath", ".[2J.[Hpath"}, + {"bell stripped", "a\x07b", "a.b"}, + {"del stripped", "a\x7fb", "a.b"}, + {"newline stripped in line mode", "a\nb", "a.b"}, + {"cr always dropped", "a\rb", "ab"}, + {"invalid utf-8 replaced not crashed", "a\xffb", "a�b"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := sanitizeLine(tt.input); got != tt.want { + t.Errorf("sanitizeLine(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestSanitizeBlock(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"newline preserved", "line1\nline2", "line1\nline2"}, + {"cr stripped, lf kept", "line1\r\nline2", "line1\nline2"}, + {"escape stripped, structure kept", "GET /\x1b]0;x\x07 HTTP/1.1\nHost: x", "GET /.]0;x. HTTP/1.1\nHost: x"}, + {"tab passes through", "a\tb", "a\tb"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := sanitizeBlock(tt.input); got != tt.want { + t.Errorf("sanitizeBlock(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} |