srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--cmd/mitmux/compare.go12
-rw-r--r--cmd/mitmux/decoder.go8
-rw-r--r--cmd/mitmux/main.go89
-rw-r--r--cmd/mitmux/pretty.go5
-rw-r--r--cmd/mitmux/sanitize.go50
-rw-r--r--cmd/mitmux/sanitize_test.go47
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)
+ }
+ })
+ }
+}