diff options
Diffstat (limited to 'cmd')
| -rw-r--r-- | cmd/mitmux/decoder.go | 150 | ||||
| -rw-r--r-- | cmd/mitmux/decoder_test.go | 85 | ||||
| -rw-r--r-- | cmd/mitmux/main.go | 75 |
3 files changed, 307 insertions, 3 deletions
diff --git a/cmd/mitmux/decoder.go b/cmd/mitmux/decoder.go new file mode 100644 index 0000000..aebb647 --- /dev/null +++ b/cmd/mitmux/decoder.go @@ -0,0 +1,150 @@ +package main + +import ( + "encoding/base64" + "encoding/hex" + "fmt" + "html" + "net/url" + "strings" +) + +// decoderOp is one transform the standalone Decoder tool can apply. +// Deliberately single-transform (not chained/pipelined like Burp's +// Decoder supports) - v1 scope, covers the encodings actually reached +// for constantly without needing pipeline-building UI. +type decoderOp int + +const ( + opURLEncode decoderOp = iota + opURLDecode + opBase64Encode + opBase64Decode + opHexEncode + opHexDecode + opHTMLEncode + opHTMLDecode +) + +var decoderOps = []struct { + name string + op decoderOp +}{ + {"URL encode", opURLEncode}, + {"URL decode", opURLDecode}, + {"Base64 encode", opBase64Encode}, + {"Base64 decode", opBase64Decode}, + {"Hex encode", opHexEncode}, + {"Hex decode", opHexDecode}, + {"HTML encode", opHTMLEncode}, + {"HTML decode", opHTMLDecode}, +} + +// applyDecoderOp runs op over input. Decode failures return a visible +// "(error: ...)" placeholder rather than an empty or stale output, so +// it's obvious the input doesn't match the selected encoding rather +// than looking like the tool did nothing. +func applyDecoderOp(op decoderOp, input string) string { + switch op { + case opURLEncode: + return urlEncodeAll(input) + case opURLDecode: + out, err := url.PathUnescape(input) + if err != nil { + return "(error: " + err.Error() + ")" + } + return out + case opBase64Encode: + return base64.StdEncoding.EncodeToString([]byte(input)) + case opBase64Decode: + out, err := decodeBase64Lenient(input) + if err != nil { + return "(error: " + err.Error() + ")" + } + return string(out) + case opHexEncode: + return hex.EncodeToString([]byte(input)) + case opHexDecode: + out, err := hex.DecodeString(strings.TrimSpace(input)) + if err != nil { + return "(error: " + err.Error() + ")" + } + return string(out) + case opHTMLEncode: + return html.EscapeString(input) + case opHTMLDecode: + return html.UnescapeString(input) + } + return "" +} + +// urlEncodeAll percent-encodes everything except RFC 3986 unreserved +// characters - standard percent-encoding (space -> %20), not +// url.QueryEscape's form-encoding behavior (space -> '+'), since a +// pentester reaching for "URL encode" almost always means the former. +func urlEncodeAll(s string) string { + var b strings.Builder + for i := 0; i < len(s); i++ { + c := s[i] + if isUnreservedURLByte(c) { + b.WriteByte(c) + } else { + fmt.Fprintf(&b, "%%%02X", c) + } + } + return b.String() +} + +func isUnreservedURLByte(c byte) bool { + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || + c == '-' || c == '_' || c == '.' || c == '~' +} + +// decodeBase64Lenient tries the common Base64 variants in turn - real +// pasted data is as likely to be unpadded and/or URL-safe as standard, +// and guessing wrong before trying the next variant is friendlier than +// making the user pick. +func decodeBase64Lenient(s string) ([]byte, error) { + s = strings.TrimSpace(s) + var lastErr error + for _, enc := range []*base64.Encoding{base64.StdEncoding, base64.URLEncoding, base64.RawStdEncoding, base64.RawURLEncoding} { + if out, err := enc.DecodeString(s); err == nil { + return out, nil + } else { + lastErr = err + } + } + return nil, lastErr +} + +func (m *model) decoderContent() string { + return applyDecoderOp(m.decoderOp, m.decoderInput.Value()) +} + +func (m *model) decoderView() string { + var b strings.Builder + b.WriteString(titleStyle.Render(" decoder ")) + b.WriteString("\n") + + for _, o := range decoderOps { + if o.op == m.decoderOp { + b.WriteString(tabActive.Render(o.name)) + } else { + b.WriteString(tabInactive.Render(o.name)) + } + } + b.WriteString("\n") + + b.WriteString(m.decoderInput.View()) + b.WriteString("\n") + b.WriteString(m.decoderOutput.View()) + b.WriteString("\n") + b.WriteString(viModeLabel(&m.decoderInput)) + b.WriteString("\n") + if m.statusMsg != "" { + b.WriteString(statusStyle.Render(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")) + return b.String() +} diff --git a/cmd/mitmux/decoder_test.go b/cmd/mitmux/decoder_test.go new file mode 100644 index 0000000..33637b4 --- /dev/null +++ b/cmd/mitmux/decoder_test.go @@ -0,0 +1,85 @@ +package main + +import "testing" + +func TestApplyDecoderOp(t *testing.T) { + tests := []struct { + name string + op decoderOp + input string + want string + }{ + {"url encode space and special chars", opURLEncode, "a b/c?d=1&e", "a%20b%2Fc%3Fd%3D1%26e"}, + {"url encode leaves unreserved alone", opURLEncode, "abc-XYZ_123.~", "abc-XYZ_123.~"}, + {"url decode percent", opURLDecode, "a%20b%2Fc", "a b/c"}, + {"url decode leaves plus literal", opURLDecode, "a+b", "a+b"}, + {"base64 encode", opBase64Encode, "hello", "aGVsbG8="}, + {"base64 decode standard padded", opBase64Decode, "aGVsbG8=", "hello"}, + {"base64 decode unpadded raw", opBase64Decode, "aGVsbG8", "hello"}, + {"base64 decode url-safe", opBase64Decode, "YWJjP2Q9MQ", "abc?d=1"}, + {"hex encode", opHexEncode, "hi", "6869"}, + {"hex decode", opHexDecode, "6869", "hi"}, + {"html encode", opHTMLEncode, `<script>alert("x")</script>`, "<script>alert("x")</script>"}, + {"html decode", opHTMLDecode, "<b>", "<b>"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := applyDecoderOp(tt.op, tt.input) + if got != tt.want { + t.Errorf("applyDecoderOp(%v, %q) = %q, want %q", tt.op, tt.input, got, tt.want) + } + }) + } +} + +func TestApplyDecoderOpErrors(t *testing.T) { + tests := []struct { + name string + op decoderOp + input string + }{ + {"invalid base64", opBase64Decode, "not valid base64!!!"}, + {"invalid hex", opHexDecode, "not hex zz"}, + {"invalid url escape", opURLDecode, "%zz"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := applyDecoderOp(tt.op, tt.input) + if len(got) < 7 || got[:7] != "(error:" { + t.Errorf("applyDecoderOp(%v, %q) = %q, want an (error: ...) placeholder", tt.op, tt.input, got) + } + }) + } +} + +// Round-trip: encode then decode should return the original for every +// encode/decode pair, across inputs including bytes that are awkward +// for each encoding (spaces, slashes, high-bit bytes where valid UTF-8). +func TestDecoderRoundTrip(t *testing.T) { + pairs := []struct { + enc, dec decoderOp + }{ + {opURLEncode, opURLDecode}, + {opBase64Encode, opBase64Decode}, + {opHexEncode, opHexDecode}, + {opHTMLEncode, opHTMLDecode}, + } + inputs := []string{ + "hello world", + "a=1&b=2/c?d", + `<script>alert(1)</script>`, + "", + "line1\nline2", + } + for _, p := range pairs { + for _, in := range inputs { + encoded := applyDecoderOp(p.enc, in) + got := applyDecoderOp(p.dec, encoded) + if got != in { + t.Errorf("round trip %v->%v: input %q -> encoded %q -> decoded %q, want %q", + p.enc, p.dec, in, encoded, got, in) + } + } + } +} diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go index ebcd666..607f21c 100644 --- a/cmd/mitmux/main.go +++ b/cmd/mitmux/main.go @@ -74,6 +74,7 @@ const ( viewRules viewIntruder viewCompare + viewDecoder viewHelp ) @@ -170,6 +171,10 @@ type model struct { compareTab detailTab compareViewport viewport.Model + decoderOp decoderOp + decoderInput viTextarea + decoderOutput viewport.Model + statusMsg string width int height int @@ -241,6 +246,10 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary, socketPath string) iresults := table.New(table.WithColumns(iresultsCols), table.WithFocused(true)) iresults.SetStyles(st) + din := newViTextarea() + din.ta.Placeholder = "text to encode/decode" + din.ta.ShowLineNumbers = false + return &model{ client: client, subCh: subCh, @@ -257,6 +266,7 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary, socketPath string) intruderTemplate: itmpl, intruderPayloads: ipayloads, intruderResults: iresults, + decoderInput: din, } } @@ -570,6 +580,11 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.viewport = viewport.New(msg.Width, h-5) m.compareViewport = viewport.New(msg.Width, h-5) + decInHeight := (h - 8) / 2 + m.decoderInput.SetWidth(msg.Width) + 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) @@ -775,6 +790,12 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if row := m.table.Cursor(); row >= 0 && row < len(m.entries) { return m, m.markOrCompare(m.entries[row].ID) } + case "d": + m.mode = viewDecoder + m.statusMsg = "" + m.decoderInput.Focus() + m.decoderOutput.SetContent(m.decoderContent()) + return m, nil case "/": m.searching = true m.searchInput.SetValue(m.query) @@ -1063,6 +1084,46 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.compareViewport, cmd = m.compareViewport.Update(msg) return m, cmd + case viewDecoder: + switch msg.String() { + case "esc": + if m.decoderInput.Mode() == viInsert { + break + } + m.mode = viewList + m.decoderInput.Blur() + return m, nil + case "ctrl+c": + return m, tea.Quit + case "?": + if m.decoderInput.Mode() != viInsert { + m.prevMode = viewDecoder + m.mode = viewHelp + return m, nil + } + case "tab": + for i, o := range decoderOps { + if o.op == m.decoderOp { + m.decoderOp = decoderOps[(i+1)%len(decoderOps)].op + break + } + } + m.decoderOutput.SetContent(m.decoderContent()) + return m, nil + case "shift+tab": + for i, o := range decoderOps { + if o.op == m.decoderOp { + m.decoderOp = decoderOps[(i-1+len(decoderOps))%len(decoderOps)].op + break + } + } + m.decoderOutput.SetContent(m.decoderContent()) + return m, nil + } + cmd := m.decoderInput.Update(msg) + m.decoderOutput.SetContent(m.decoderContent()) + return m, cmd + case viewHelp: switch msg.String() { case "ctrl+c": @@ -1100,6 +1161,8 @@ func (m *model) View() string { body = m.intruderView() case viewCompare: body = m.compareView() + case viewDecoder: + body = m.decoderView() default: body = m.listView() } @@ -1119,7 +1182,7 @@ func (m *model) statusBar() string { } view := map[viewMode]string{ viewList: "history", viewDetail: "detail", viewRepeater: "repeater", - viewRules: "rules", viewIntruder: "intruder", viewCompare: "comparer", + viewRules: "rules", viewIntruder: "intruder", viewCompare: "comparer", viewDecoder: "decoder", }[m.mode] return statusBarStyle.Render(fmt.Sprintf(" mitmux · proxy %s%s · %s ", proxy, count, view)) } @@ -1149,6 +1212,7 @@ func (m *model) helpView() string { "i open in Intruder", "f toggle flag (★ mark this, revisit later)", "c mark for comparison, then press c on another entry to diff", + "d decoder (URL/Base64/Hex/HTML encode/decode)", "/ search: plain text, host:value, AND/OR/NOT,", " status:404 / status:4xx / status:>=400,", " source:repeater, flagged:true", @@ -1169,6 +1233,11 @@ func (m *model) helpView() string { "↑/↓ or j/k scroll (also g/G, ctrl+u/d - same as history list)", "esc / q back to history", ) + section("Decoder - vi-modal input (same as Repeater/Intruder)", + "i to edit type/paste text, output updates live", + "tab/shift+tab cycle transform: URL, Base64, Hex, HTML - encode/decode", + "esc back to history", + ) section("Repeater / Intruder editors - vi-modal", "Starts in NORMAL mode (not insert) - press i to type, esc to stop.", "h j k l left/down/up/right 0 / $ line start/end", @@ -1225,9 +1294,9 @@ func (m *model) listView() string { b.WriteString(statusStyle.Render(m.statusMsg)) b.WriteString("\n") } - help := "enter view · r repeater · i intruder · f flag · c compare · / search · m rules · ? help · q quit" + help := "enter view · r/i/c/d tools · f flag · / search · m rules · ? help · q quit" if m.query != "" { - help = "enter view · r repeater · i intruder · f flag · c compare · / search · esc clear filter · ? help · q quit" + help = "enter view · r/i/c/d tools · f flag · / search · esc clear filter · ? help · q quit" } b.WriteString(helpStyle.Render(help)) return b.String() |