srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/cmd
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2024-09-23 21:33:00 +0200
committersrdusr <[email protected]>2024-09-23 21:33:00 +0200
commitc2443f27ef5a844f045c038c7689d217d1dbf0c4 (patch)
tree24f45e2047dc3bb0f9e34b553d12157adc5e9b39 /cmd
parentaae93b4575e10d223c6cdd8722ca0cce2d47397c (diff)
downloadmitmux-c2443f27ef5a844f045c038c7689d217d1dbf0c4.tar.gz
mitmux-c2443f27ef5a844f045c038c7689d217d1dbf0c4.zip
Match-and-replace: header rewrite rules
Implements build-order step 6, scoped to headers only for this pass - see PLAN.md for why bodies are a separate problem (request-body capture currently depends on streaming straight through, which a body-rewriting rule would have to interrupt; deciding what "exact" means for a rule-modified request needs its own pass, not a rushed add-on to this one). internal/rules: Rule type and ApplyHeaders, which serializes a Header map to a raw "Name: value\r\n" block, runs enabled rules' match/replace over that text, and reparses it - operating on text rather than per-value substitution is what lets a rule add or remove a header, not just rewrite one, matching how Burp's header match/replace works. Invalid rule output (bad regex, unparseable result) leaves the header map untouched rather than corrupting the request. internal/store: rules table + CRUD. internal/proxy: forward() fetches enabled rules for each scope and applies them to outReq.Header / resp.Header, positioned so the existing capture/history pipeline is untouched - request_raw keeps showing what the client actually sent and response_raw what the origin actually sent, while the wire itself reflects the rules. Deliberate split: match-and-replace transforms traffic, it doesn't rewrite the audit trail. internal/ipc gains rules_list/rules_save/rules_delete/rules_toggle. cmd/mitmux gains a rules view ('m' from history) with add/edit/delete/toggle and a small form (name, match, replace, scope, regex). Verified live against real external traffic, not just local echoes: a request-scope rule rewriting User-Agent, confirmed via httpbin.org's own header echo that the origin received the rewritten value while curl sent the real one; a response-scope rule rewriting the Server header, confirmed the client actually received the rewritten value; disabling a rule confirmed via a follow-up request that it stops applying; and throughout, history continued showing the pre-rule original on both sides, confirming the capture/transform split holds.
Diffstat (limited to 'cmd')
-rw-r--r--cmd/mitmux/main.go327
1 files changed, 325 insertions, 2 deletions
diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go
index f47461c..68d42ed 100644
--- a/cmd/mitmux/main.go
+++ b/cmd/mitmux/main.go
@@ -20,6 +20,7 @@ import (
"mitmux/internal/ca"
"mitmux/internal/ipc"
+ "mitmux/internal/rules"
"mitmux/internal/store"
)
@@ -71,6 +72,7 @@ const (
viewList viewMode = iota
viewDetail
viewRepeater
+ viewRules
)
type detailTab int
@@ -87,6 +89,16 @@ const (
focusResponse
)
+type ruleField int
+
+const (
+ fieldName ruleField = iota
+ fieldMatch
+ fieldReplace
+ fieldScope
+ fieldRegex
+)
+
type model struct {
client *ipc.Client
subCh <-chan store.Summary
@@ -112,6 +124,19 @@ type model struct {
repeaterResult *ipc.EntryDetail
sending bool
+ rulesTable table.Model
+ ruleRows []rules.Rule
+
+ ruleForm bool // true while the add/edit form is active (vs the rule list)
+ ruleEditingID int64
+ ruleEnabled bool
+ ruleName textinput.Model
+ ruleMatch textinput.Model
+ ruleReplace textinput.Model
+ ruleScope string // "request" or "response"
+ ruleRegex bool
+ ruleField ruleField
+
statusMsg string
width int
height int
@@ -145,6 +170,24 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary) *model {
si.Prompt = "/"
si.Placeholder = "search - plain text, or host:example.com / AND / OR / NOT"
+ rulesCols := []table.Column{
+ {Title: "On", Width: 3},
+ {Title: "Name", Width: 16},
+ {Title: "Scope", Width: 9},
+ {Title: "Match", Width: 24},
+ {Title: "Replace", Width: 24},
+ {Title: "Regex", Width: 5},
+ }
+ rt := table.New(table.WithColumns(rulesCols), table.WithFocused(true))
+ rt.SetStyles(st)
+
+ nameIn := textinput.New()
+ nameIn.Placeholder = "rule name"
+ matchIn := textinput.New()
+ matchIn.Placeholder = "match text or regex"
+ replaceIn := textinput.New()
+ replaceIn.Placeholder = "replacement"
+
return &model{
client: client,
subCh: subCh,
@@ -152,6 +195,11 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary) *model {
table: t,
reqArea: ta,
searchInput: si,
+ rulesTable: rt,
+ ruleName: nameIn,
+ ruleMatch: matchIn,
+ ruleReplace: replaceIn,
+ ruleScope: "request",
}
}
@@ -227,6 +275,106 @@ func (m *model) enterRepeater(d *ipc.EntryDetail) {
m.statusMsg = ""
}
+type rulesLoadedMsg struct {
+ rules []rules.Rule
+ err error
+}
+
+type ruleWriteDoneMsg struct {
+ action string // "saved", "deleted", "toggled" - for the status line
+ err error
+}
+
+func (m *model) loadRules() tea.Msg {
+ rs, err := m.client.ListRules()
+ return rulesLoadedMsg{rules: rs, err: err}
+}
+
+func (m *model) saveRule(r rules.Rule) tea.Cmd {
+ return func() tea.Msg {
+ _, err := m.client.SaveRule(r)
+ return ruleWriteDoneMsg{action: "saved", err: err}
+ }
+}
+
+func (m *model) deleteSelectedRule() tea.Cmd {
+ row := m.rulesTable.Cursor()
+ if row < 0 || row >= len(m.ruleRows) {
+ return nil
+ }
+ id := m.ruleRows[row].ID
+ return func() tea.Msg {
+ err := m.client.DeleteRule(id)
+ return ruleWriteDoneMsg{action: "deleted", err: err}
+ }
+}
+
+func (m *model) toggleSelectedRule() tea.Cmd {
+ row := m.rulesTable.Cursor()
+ if row < 0 || row >= len(m.ruleRows) {
+ return nil
+ }
+ r := m.ruleRows[row]
+ return func() tea.Msg {
+ err := m.client.SetRuleEnabled(r.ID, !r.Enabled)
+ return ruleWriteDoneMsg{action: "toggled", err: err}
+ }
+}
+
+// enterRuleForm opens the add/edit form. r is nil to add a new rule.
+func (m *model) enterRuleForm(r *rules.Rule) {
+ m.ruleForm = true
+ m.ruleField = fieldName
+ if r == nil {
+ m.ruleEditingID = 0
+ m.ruleEnabled = true
+ m.ruleName.SetValue("")
+ m.ruleMatch.SetValue("")
+ m.ruleReplace.SetValue("")
+ m.ruleScope = "request"
+ m.ruleRegex = false
+ } else {
+ m.ruleEditingID = r.ID
+ m.ruleEnabled = r.Enabled
+ m.ruleName.SetValue(r.Name)
+ m.ruleMatch.SetValue(r.Match)
+ m.ruleReplace.SetValue(r.Replace)
+ m.ruleScope = r.Scope
+ m.ruleRegex = r.IsRegex
+ }
+ m.ruleName.Focus()
+ m.ruleMatch.Blur()
+ m.ruleReplace.Blur()
+}
+
+func (m *model) ruleFromForm() rules.Rule {
+ return rules.Rule{
+ ID: m.ruleEditingID,
+ Enabled: m.ruleEnabled,
+ Name: m.ruleName.Value(),
+ Scope: m.ruleScope,
+ Part: "header",
+ Match: m.ruleMatch.Value(),
+ Replace: m.ruleReplace.Value(),
+ IsRegex: m.ruleRegex,
+ }
+}
+
+// focusRuleField moves input focus to m.ruleField, blurring the others.
+func (m *model) focusRuleField() {
+ m.ruleName.Blur()
+ m.ruleMatch.Blur()
+ m.ruleReplace.Blur()
+ switch m.ruleField {
+ case fieldName:
+ m.ruleName.Focus()
+ case fieldMatch:
+ m.ruleMatch.Focus()
+ case fieldReplace:
+ m.ruleReplace.Focus()
+ }
+}
+
func (m *model) Init() tea.Cmd {
return tea.Batch(m.loadList, m.waitForEntry)
}
@@ -245,6 +393,13 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.reqArea.SetWidth(msg.Width)
m.reqArea.SetHeight(reqHeight)
m.respView = viewport.New(msg.Width, msg.Height-6-reqHeight)
+
+ m.rulesTable.SetWidth(msg.Width)
+ m.rulesTable.SetHeight(msg.Height - 5)
+ formWidth := msg.Width - 12
+ m.ruleName.Width = formWidth
+ m.ruleMatch.Width = formWidth
+ m.ruleReplace.Width = formWidth
return m, nil
case listLoadedMsg:
@@ -300,6 +455,24 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.respView.GotoTop()
return m, nil
+ case rulesLoadedMsg:
+ if msg.err != nil {
+ m.statusMsg = "rules error: " + msg.err.Error()
+ return m, nil
+ }
+ m.ruleRows = msg.rules
+ m.rulesTable.SetRows(rulesRowsFor(m.ruleRows))
+ return m, nil
+
+ case ruleWriteDoneMsg:
+ if msg.err != nil {
+ m.statusMsg = msg.action + " error: " + msg.err.Error()
+ return m, nil
+ }
+ m.ruleForm = false
+ m.statusMsg = "rule " + msg.action
+ return m, m.loadRules
+
case tea.KeyMsg:
switch m.mode {
case viewList:
@@ -341,6 +514,10 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.searchInput.SetValue(m.query)
m.searchInput.CursorEnd()
return m, m.searchInput.Focus()
+ case "m":
+ m.mode = viewRules
+ m.statusMsg = ""
+ return m, m.loadRules
case "esc":
if m.query != "" {
m.query = ""
@@ -413,6 +590,78 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.respView, cmd = m.respView.Update(msg)
}
return m, cmd
+
+ case viewRules:
+ if m.ruleForm {
+ switch msg.String() {
+ case "esc":
+ m.ruleForm = false
+ return m, nil
+ case "ctrl+c":
+ return m, tea.Quit
+ case "ctrl+s":
+ return m, m.saveRule(m.ruleFromForm())
+ case "tab":
+ m.ruleField = (m.ruleField + 1) % 5
+ m.focusRuleField()
+ return m, nil
+ case "shift+tab":
+ m.ruleField = (m.ruleField + 4) % 5
+ m.focusRuleField()
+ return m, nil
+ }
+ if m.ruleField == fieldScope || m.ruleField == fieldRegex {
+ switch msg.String() {
+ case "left", "right", "enter", " ":
+ if m.ruleField == fieldScope {
+ if m.ruleScope == "request" {
+ m.ruleScope = "response"
+ } else {
+ m.ruleScope = "request"
+ }
+ } else {
+ m.ruleRegex = !m.ruleRegex
+ }
+ return m, nil
+ }
+ }
+ var cmd tea.Cmd
+ switch m.ruleField {
+ case fieldName:
+ m.ruleName, cmd = m.ruleName.Update(msg)
+ case fieldMatch:
+ m.ruleMatch, cmd = m.ruleMatch.Update(msg)
+ case fieldReplace:
+ m.ruleReplace, cmd = m.ruleReplace.Update(msg)
+ }
+ return m, cmd
+ }
+
+ switch msg.String() {
+ case "q", "esc":
+ m.mode = viewList
+ return m, nil
+ case "ctrl+c":
+ return m, tea.Quit
+ case "a":
+ m.enterRuleForm(nil)
+ return m, nil
+ case "enter", "e":
+ if row := m.rulesTable.Cursor(); row >= 0 && row < len(m.ruleRows) {
+ sel := m.ruleRows[row]
+ m.enterRuleForm(&sel)
+ }
+ return m, nil
+ case "d":
+ m.statusMsg = ""
+ return m, m.deleteSelectedRule()
+ case " ":
+ m.statusMsg = ""
+ return m, m.toggleSelectedRule()
+ }
+ var cmd tea.Cmd
+ m.rulesTable, cmd = m.rulesTable.Update(msg)
+ return m, cmd
}
}
return m, nil
@@ -427,6 +676,11 @@ func (m *model) View() string {
return m.detailView()
case viewRepeater:
return m.repeaterView()
+ case viewRules:
+ if m.ruleForm {
+ return m.ruleFormView()
+ }
+ return m.rulesView()
default:
return m.listView()
}
@@ -458,9 +712,9 @@ func (m *model) listView() string {
b.WriteString(statusStyle.Render(m.statusMsg))
b.WriteString("\n")
}
- help := "↑/↓ navigate · enter view · r repeater · / search · q quit"
+ help := "↑/↓ navigate · enter view · r repeater · / search · m rules · q quit"
if m.query != "" {
- help = "↑/↓ navigate · enter view · r repeater · / search · esc clear filter · q quit"
+ help = "↑/↓ navigate · enter view · r repeater · / search · m rules · esc clear filter · q quit"
}
b.WriteString(helpStyle.Render(help))
return b.String()
@@ -523,6 +777,75 @@ func (m *model) repeaterView() string {
return b.String()
}
+func (m *model) rulesView() string {
+ var b strings.Builder
+ b.WriteString(titleStyle.Render(fmt.Sprintf(" match/replace rules (%d) - headers only for now ", len(m.ruleRows))))
+ b.WriteString("\n")
+ b.WriteString(m.rulesTable.View())
+ b.WriteString("\n")
+ if m.statusMsg != "" {
+ b.WriteString(statusStyle.Render(m.statusMsg))
+ b.WriteString("\n")
+ }
+ b.WriteString(helpStyle.Render("a add · enter/e edit · d delete · space toggle · esc back · q quit"))
+ return b.String()
+}
+
+func (m *model) ruleFormView() string {
+ var b strings.Builder
+ title := " add rule "
+ if m.ruleEditingID != 0 {
+ title = fmt.Sprintf(" edit rule #%d ", m.ruleEditingID)
+ }
+ b.WriteString(titleStyle.Render(title))
+ b.WriteString("\n\n")
+
+ label := func(field ruleField, text string) string {
+ if m.ruleField == field {
+ return tabActive.Render(text)
+ }
+ return tabInactive.Render(text)
+ }
+
+ b.WriteString(label(fieldName, "Name") + "\n")
+ b.WriteString(m.ruleName.View() + "\n\n")
+ b.WriteString(label(fieldMatch, "Match") + "\n")
+ b.WriteString(m.ruleMatch.View() + "\n\n")
+ b.WriteString(label(fieldReplace, "Replace") + "\n")
+ b.WriteString(m.ruleReplace.View() + "\n\n")
+
+ scopeText := fmt.Sprintf("Scope: %s (◀▶ to change)", m.ruleScope)
+ b.WriteString(label(fieldScope, scopeText) + "\n\n")
+ regexText := "Regex: off (◀▶ to change)"
+ if m.ruleRegex {
+ regexText = "Regex: on (◀▶ to change)"
+ }
+ b.WriteString(label(fieldRegex, regexText) + "\n\n")
+
+ if m.statusMsg != "" {
+ b.WriteString(statusStyle.Render(m.statusMsg))
+ b.WriteString("\n")
+ }
+ b.WriteString(helpStyle.Render("tab/shift+tab move · ctrl+s save · esc cancel · ctrl+c quit"))
+ return b.String()
+}
+
+func rulesRowsFor(rs []rules.Rule) []table.Row {
+ rows := make([]table.Row, len(rs))
+ for i, r := range rs {
+ on := " "
+ if r.Enabled {
+ on = "✓"
+ }
+ regex := ""
+ if r.IsRegex {
+ regex = "yes"
+ }
+ rows[i] = table.Row{on, r.Name, r.Scope, r.Match, r.Replace, regex}
+ }
+ return rows
+}
+
func exactSuffix(exact bool) string {
if exact {
return ", exact"