srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--PLAN.md21
-rw-r--r--README.md11
-rw-r--r--cmd/mitmux/main.go229
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()
}