diff options
| author | srdusr <[email protected]> | 2024-09-23 21:33:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2024-09-23 21:33:00 +0200 |
| commit | c2443f27ef5a844f045c038c7689d217d1dbf0c4 (patch) | |
| tree | 24f45e2047dc3bb0f9e34b553d12157adc5e9b39 /cmd | |
| parent | aae93b4575e10d223c6cdd8722ca0cce2d47397c (diff) | |
| download | mitmux-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.go | 327 |
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" |