From 2bb1425dd0da46d8a3df0b888411f21340783d71 Mon Sep 17 00:00:00 2001 From: srdusr <99972264+srdusr@users.noreply.github.com> Date: Wed, 18 Mar 2026 16:15:00 +0200 Subject: Multiple concurrent Repeater tabs Repeater previously had one shared request/response buffer - sending a new entry to Repeater silently overwrote whatever was already open, even mid-edit. Replaced the singular reqArea/respView/repeaterScheme/ etc. model fields with a []*repeaterTab slice plus an active index; 'r' now opens a new tab and switches to it, existing tabs stay put. New keys, all gated to normal mode so they stay inert while typing (]/[ show up in JSON bodies constantly, and ctrl+w is the textarea's own delete-word-backward that must still work mid-edit): ] next tab [ previous tab ctrl+w close the active tab (falls back to a neighbor, or to the history list if it was the last one) Async send results now carry the tab index they belong to, so a slow send whose response lands after the user has switched tabs (or closed one) updates the right tab rather than whichever happens to be active when the result arrives; the status line and response pane only reflect it live if that tab is still the one being viewed. Verified live in tmux against a running daemon: opened two tabs from different history entries, confirmed independent buffers, sent from a background tab while another was active and confirmed the result routed to the correct (non-visible) tab, switched with ]/[, closed with ctrl+w down to zero tabs (falls back to the history list), and confirmed [, ], and ctrl+w are all correctly inert in insert mode (typed "[a]" literally, ctrl+w did textarea's word-delete instead of closing the tab). go build/vet/gofmt/test/mod tidy all clean. --- PLAN.md | 21 ++++- README.md | 11 ++- cmd/mitmux/main.go | 229 ++++++++++++++++++++++++++++++++++++++++------------- 3 files changed, 199 insertions(+), 62 deletions(-) diff --git a/PLAN.md b/PLAN.md index 67e0e77..2dac629 100644 --- a/PLAN.md +++ b/PLAN.md @@ -111,10 +111,23 @@ percent-encoding (space <-> %20), not Go's url.QueryEscape's form- encoding behavior (space <-> '+'), since "URL encode" for a pentester almost always means the former. -Still open from "worth considering": multiple concurrent Repeater tabs, -Intruder payload processing (encoding/case rules) and grep-match/ -grep-extract on results, CA install UX per OS, multiple proxy listeners -and upstream proxy chaining. None of these are started yet. +Shipped since: a standalone Decoder tool ('d') - see above; multiple +concurrent Repeater tabs - 'r' from the history list or detail view now +opens a NEW tab rather than overwriting whatever was already open, +`]`/`[` switch tabs, `ctrl+w` closes the active one (all three gated to +normal mode, so they're inert while typing - `[`/`]` are common JSON +body characters and `ctrl+w` is a textarea binding for delete-word- +backward that must still work while composing a request). Each tab owns +its own request buffer, response view, and send-in-flight state; a slow +send whose result lands after the user has switched away still updates +the correct tab (send results carry the tab index they belong to), and +the status line/response pane it's shown in only updates live if that +tab is still the one on screen. + +Still open from "worth considering": Intruder payload processing +(encoding/case rules) and grep-match/grep-extract on results, CA install +UX per OS, multiple proxy listeners and upstream proxy chaining. None of +these are started yet. Skipped deliberately (from the research, matches this tool's stated scope): active/passive vulnerability scanning, plugin marketplace, diff --git a/README.md b/README.md index 9bc683e..c91e455 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,10 @@ list of what's deliberately not implemented (and why), see column filters like `host:example.com`, and `AND`/`OR`/`NOT`. - **Repeater**: edit and resend a raw request. What you type is what goes on the wire - no normalization, no auto-fixed `Content-Length`, - no "helpful" reformatting. That's the point of a Repeater. + no "helpful" reformatting. That's the point of a Repeater. Multiple + tabs: sending an entry to Repeater opens a new tab rather than + replacing whatever's already there, so you can iterate on several + requests side by side. - **Intruder** (Sniper only): mark positions in a request template with `§markers§`, supply a payload list, fuzz one position at a time against a shared payload set. Results land in the same history table @@ -209,6 +212,12 @@ switches panes. In Intruder's template pane specifically, `ctrl+g` inserts a `§` marker at the cursor if typing the character directly isn't convenient on your keyboard/terminal. +Repeater supports multiple concurrent tabs - each open request/response +pair is independent. `]`/`[` switch to the next/previous tab, `ctrl+w` +closes the active one. All three only fire in normal mode, so they +don't interfere with typing (`[`/`]` show up in JSON bodies constantly, +and `ctrl+w` is the editor's own delete-word-backward while composing). + ### Match-and-replace rules Press `m` from the history view. Rules match request or response diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go index 607f21c..31491e9 100644 --- a/cmd/mitmux/main.go +++ b/cmd/mitmux/main.go @@ -92,6 +92,20 @@ const ( focusResponse ) +// repeaterTab is one open Repeater buffer - its own request editor, +// response view and send state, independent of every other open tab. +// Burp/Caido both let you keep several requests open in Repeater at +// once for side-by-side iteration; a single shared buffer that every +// "send to repeater" overwrote was the gap this closes. +type repeaterTab struct { + scheme, host string + reqArea viTextarea + respView viewport.Model + focus repeaterFocus + result *ipc.EntryDetail + sending bool +} + type ruleField int const ( @@ -129,13 +143,8 @@ type model struct { activeTab detailTab prettyMode bool // display-only JSON reformatting of the response body - reqArea viTextarea - respView viewport.Model - repeaterFocus repeaterFocus - repeaterScheme string - repeaterHost string - repeaterResult *ipc.EntryDetail - sending bool + repeaterTabs []*repeaterTab + repeaterIndex int rulesTable table.Model ruleRows []rules.Rule @@ -201,10 +210,6 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary, socketPath string) st.Selected = st.Selected.Foreground(lipgloss.Color("0")).Background(lipgloss.Color("39")).Bold(true) t.SetStyles(st) - ta := newViTextarea() - ta.ta.Placeholder = "raw request bytes" - ta.ta.ShowLineNumbers = false - si := textinput.New() si.Prompt = "/" si.Placeholder = "search - plain text, host:x, status:404/4xx/>=400, source:repeater, flagged:true" @@ -256,7 +261,7 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary, socketPath string) socketPath: socketPath, mode: viewList, table: t, - reqArea: ta, + repeaterIndex: -1, searchInput: si, rulesTable: rt, ruleName: nameIn, @@ -290,8 +295,9 @@ type detailLoadedMsg struct { } type repeatSentMsg struct { - detail *ipc.EntryDetail - err error + detail *ipc.EntryDetail + err error + tabIndex int // which repeater tab this send belongs to } type statusLoadedMsg struct { @@ -382,30 +388,77 @@ func (m *model) loadCompare(idA, idB int64) tea.Cmd { } } +// activeRepeaterTab returns the currently selected tab, or nil if none +// are open yet. +func (m *model) activeRepeaterTab() *repeaterTab { + if m.repeaterIndex < 0 || m.repeaterIndex >= len(m.repeaterTabs) { + return nil + } + return m.repeaterTabs[m.repeaterIndex] +} + +// sizeRepeaterTab applies the current terminal dimensions to one tab's +// editor and response viewport. Called for every open tab on resize, +// and for a single new tab right after creation (which otherwise +// wouldn't get sized until the next WindowSizeMsg). +func (m *model) sizeRepeaterTab(t *repeaterTab) { + h := m.height - 1 + reqHeight := (h - 6) / 2 + t.reqArea.SetWidth(m.width) + t.reqArea.SetHeight(reqHeight) + t.respView = viewport.New(m.width, h-6-reqHeight) +} + +// closeRepeaterTab removes the tab at idx and moves the active index +// onto a neighbor so closing the last tab never leaves a stale +// out-of-range selection. +func (m *model) closeRepeaterTab(idx int) { + if idx < 0 || idx >= len(m.repeaterTabs) { + return + } + m.repeaterTabs = append(m.repeaterTabs[:idx], m.repeaterTabs[idx+1:]...) + if m.repeaterIndex >= len(m.repeaterTabs) { + m.repeaterIndex = len(m.repeaterTabs) - 1 + } +} + func (m *model) sendRepeat() tea.Cmd { - scheme, host := m.repeaterScheme, m.repeaterHost + t := m.activeRepeaterTab() + if t == nil { + return nil + } + scheme, host := t.scheme, t.host // The textarea only understands LF; HTTP/1.1 requires CRLF. Restoring // it here means a body containing its own bare LF line breaks (a // multi-line JSON/XML payload, say) gets those normalized to CRLF too // - a known, narrow trade-off for being able to edit the request as // text at all. Headers and single-line bodies, the common case, are // unaffected. - raw := []byte(strings.ReplaceAll(m.reqArea.Value(), "\n", "\r\n")) + raw := []byte(strings.ReplaceAll(t.reqArea.Value(), "\n", "\r\n")) + tabIdx := m.repeaterIndex return func() tea.Msg { d, err := m.client.Repeat(scheme, host, raw) - return repeatSentMsg{detail: d, err: err} + return repeatSentMsg{detail: d, err: err, tabIndex: tabIdx} } } -// enterRepeater seeds the repeater view from an already-loaded entry. +// enterRepeater opens a NEW repeater tab seeded from an already-loaded +// entry and switches to it - existing tabs stay open, matching Burp's +// "send to repeater" behavior of accumulating tabs rather than +// overwriting whatever was already there. func (m *model) enterRepeater(d *ipc.EntryDetail) { - m.repeaterScheme = d.Scheme - m.repeaterHost = d.Host - m.reqArea.SetValue(strings.ReplaceAll(string(d.RequestRaw), "\r\n", "\n")) - m.reqArea.Focus() - m.respView.SetContent("") - m.repeaterResult = nil - m.repeaterFocus = focusRequest + t := &repeaterTab{ + scheme: d.Scheme, + host: d.Host, + reqArea: newViTextarea(), + } + t.reqArea.ta.ShowLineNumbers = false + t.reqArea.SetValue(strings.ReplaceAll(string(d.RequestRaw), "\r\n", "\n")) + t.reqArea.Focus() + t.focus = focusRequest + m.sizeRepeaterTab(t) + m.repeaterTabs = append(m.repeaterTabs, t) + m.repeaterIndex = len(m.repeaterTabs) - 1 m.mode = viewRepeater m.statusMsg = "" } @@ -585,10 +638,9 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.decoderInput.SetHeight(decInHeight) m.decoderOutput = viewport.New(msg.Width, h-8-decInHeight) - reqHeight := (h - 6) / 2 - m.reqArea.SetWidth(msg.Width) - m.reqArea.SetHeight(reqHeight) - m.respView = viewport.New(msg.Width, h-6-reqHeight) + for _, t := range m.repeaterTabs { + m.sizeRepeaterTab(t) + } m.rulesTable.SetWidth(msg.Width) m.rulesTable.SetHeight(h - 5) @@ -682,15 +734,25 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case repeatSentMsg: - m.sending = false + if msg.tabIndex < 0 || msg.tabIndex >= len(m.repeaterTabs) { + // Tab was closed while the send was in flight - drop the result. + return m, nil + } + t := m.repeaterTabs[msg.tabIndex] + t.sending = false + active := msg.tabIndex == m.repeaterIndex if msg.err != nil { - m.statusMsg = "send error: " + msg.err.Error() + if active { + m.statusMsg = "send error: " + msg.err.Error() + } return m, nil } - m.repeaterResult = msg.detail - m.statusMsg = fmt.Sprintf("-> %d (%s)", msg.detail.StatusCode, msg.detail.Duration.Round(time.Millisecond)) - m.respView.SetContent(detailBody(msg.detail, tabResponse)) - m.respView.GotoTop() + t.result = msg.detail + if active { + m.statusMsg = fmt.Sprintf("-> %d (%s)", msg.detail.StatusCode, msg.detail.Duration.Round(time.Millisecond)) + } + t.respView.SetContent(detailBody(msg.detail, tabResponse)) + t.respView.GotoTop() return m, nil case rulesLoadedMsg: @@ -865,48 +927,79 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd case viewRepeater: + t := m.activeRepeaterTab() + if t == nil { + m.mode = viewList + return m, nil + } + // Tab-management keys (]/[ switch tabs, ctrl+w closes one) only + // fire outside insert mode - otherwise they'd be untypeable + // characters (or, for ctrl+w, steal the textarea's own + // delete-word-backward binding) while composing a request. + inInsert := t.focus == focusRequest && t.reqArea.Mode() == viInsert switch msg.String() { case "esc": // In insert mode, esc belongs to the textarea - it drops // to normal mode without leaving the view, matching vi. // Only back out when already in normal mode (or focus is // elsewhere entirely). - if m.repeaterFocus == focusRequest && m.reqArea.Mode() == viInsert { + if inInsert { break } m.mode = viewList - m.reqArea.Blur() + t.reqArea.Blur() return m, nil case "ctrl+c": return m, tea.Quit case "?": - if m.repeaterFocus != focusRequest || m.reqArea.Mode() != viInsert { + if !inInsert { m.prevMode = viewRepeater m.mode = viewHelp return m, nil } case "ctrl+r": - if !m.sending { - m.sending = true + if !t.sending { + t.sending = true m.statusMsg = "sending..." return m, m.sendRepeat() } return m, nil case "tab": - if m.repeaterFocus == focusRequest { - m.repeaterFocus = focusResponse - m.reqArea.Blur() + if t.focus == focusRequest { + t.focus = focusResponse + t.reqArea.Blur() } else { - m.repeaterFocus = focusRequest - m.reqArea.Focus() + t.focus = focusRequest + t.reqArea.Focus() + } + return m, nil + case "]": + if !inInsert && len(m.repeaterTabs) > 1 { + m.repeaterIndex = (m.repeaterIndex + 1) % len(m.repeaterTabs) + m.statusMsg = "" } return m, nil + case "[": + if !inInsert && len(m.repeaterTabs) > 1 { + m.repeaterIndex = (m.repeaterIndex - 1 + len(m.repeaterTabs)) % len(m.repeaterTabs) + m.statusMsg = "" + } + return m, nil + case "ctrl+w": + if !inInsert { + m.closeRepeaterTab(m.repeaterIndex) + if len(m.repeaterTabs) == 0 { + m.mode = viewList + } + m.statusMsg = "" + return m, nil + } } var cmd tea.Cmd - if m.repeaterFocus == focusRequest { - cmd = m.reqArea.Update(msg) + if t.focus == focusRequest { + cmd = t.reqArea.Update(msg) } else { - m.respView, cmd = m.respView.Update(msg) + t.respView, cmd = t.respView.Update(msg) } return m, cmd @@ -1249,6 +1342,8 @@ func (m *model) helpView() string { "gg / G top/bottom of buffer esc back to normal mode", "ctrl+r send / start attack tab switch pane", "ctrl+g (Intruder template only) insert a § marker at cursor", + "]/[ (Repeater only) next/previous tab", + "ctrl+w (Repeater only) close current tab", ) section("Rules", "a add rule enter / e edit selected", @@ -1337,16 +1432,36 @@ func (m *model) detailView() string { func (m *model) repeaterView() string { var b strings.Builder - title := fmt.Sprintf(" repeater - %s://%s ", m.repeaterScheme, m.repeaterHost) + t := m.activeRepeaterTab() + if t == nil { + b.WriteString(titleStyle.Render(" repeater ")) + b.WriteString("\n\nno tabs open - press r on a history entry\n") + b.WriteString(helpStyle.Render("esc back · ? help · ctrl+c quit")) + return b.String() + } + + if len(m.repeaterTabs) > 1 { + for i, rt := range m.repeaterTabs { + label := fmt.Sprintf(" %d:%s ", i+1, rt.host) + if i == m.repeaterIndex { + b.WriteString(tabActive.Render(label)) + } else { + b.WriteString(tabInactive.Render(label)) + } + } + b.WriteString("\n") + } + + title := fmt.Sprintf(" repeater - %s://%s ", t.scheme, t.host) b.WriteString(titleStyle.Render(title)) b.WriteString("\n") reqLabel := "Request (editable)" respLabel := "Response" - if m.repeaterResult != nil { - respLabel = fmt.Sprintf("Response (%d bytes%s)", len(m.repeaterResult.ResponseRaw), exactSuffix(m.repeaterResult.ResponseExact)) + if t.result != nil { + respLabel = fmt.Sprintf("Response (%d bytes%s)", len(t.result.ResponseRaw), exactSuffix(t.result.ResponseExact)) } - if m.repeaterFocus == focusRequest { + if t.focus == focusRequest { b.WriteString(tabActive.Render(reqLabel)) b.WriteString(tabInactive.Render(respLabel)) } else { @@ -1354,19 +1469,19 @@ func (m *model) repeaterView() string { b.WriteString(tabActive.Render(respLabel)) } b.WriteString("\n") - b.WriteString(m.reqArea.View()) + b.WriteString(t.reqArea.View()) b.WriteString("\n") - b.WriteString(m.respView.View()) + b.WriteString(t.respView.View()) b.WriteString("\n") - if m.repeaterFocus == focusRequest { - b.WriteString(viModeLabel(&m.reqArea)) + if t.focus == focusRequest { + b.WriteString(viModeLabel(&t.reqArea)) b.WriteString("\n") } if m.statusMsg != "" { b.WriteString(statusStyle.Render(m.statusMsg)) b.WriteString("\n") } - b.WriteString(helpStyle.Render("i to edit (vi keys) · ctrl+r send · tab switch pane · esc back · ? help · ctrl+c quit")) + 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")) return b.String() } -- cgit v1.2.3