srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2026-03-17 09:44:00 +0200
committersrdusr <[email protected]>2026-03-17 09:44:00 +0200
commit80354144ddc39d33204ab47153c33bdb111b5544 (patch)
tree39247cadcfe0e375437c434cdc4a548c0196aa11
parent66d00f513e30d0746c1d5b4cd3b0c4a34a243928 (diff)
downloadmitmux-80354144ddc39d33204ab47153c33bdb111b5544.tar.gz
mitmux-80354144ddc39d33204ab47153c33bdb111b5544.zip
Standalone Decoder: URL/Base64/Hex/HTML encode & decode
First of the remaining "worth considering" items. A self-contained tool ('d' from the history list, not seeded from any entry - this is for arbitrary snippets, pasted tokens, encoded parameter values) with a vi-modal input pane and a live output pane that updates on every keystroke and every transform switch (tab/shift+tab cycles through the 8 transforms). decoder.go is pure logic, deliberately kept separate from the TUI wiring so it's directly testable: urlEncodeAll implements strict RFC 3986 percent-encoding (space -> %20) rather than using Go's url.QueryEscape, whose form-encoding behavior (space -> '+') isn't what "URL encode" means to a pentester reaching for this tool. Base64 decode tries standard/URL-safe/padded/unpadded encodings in turn rather than requiring the user to know which one they're looking at - real pasted data is as likely to be one as the other. Decode failures return a visible "(error: ...)" placeholder rather than blanking the output, so a bad guess at the transform is obviously wrong rather than looking like nothing happened. decoder_test.go covers each transform directly, three "this input isn't valid for this transform" error cases, and a round-trip matrix (all 4 encode/decode pairs against 5 inputs chosen to be awkward for at least one encoding - spaces, slashes, HTML-special characters, empty string, embedded newlines) confirming encode-then-decode always recovers the original. Single-transform only, not chained/pipelined like Burp's Decoder - v1 scope, tracked in PLAN.md. Verified live: typed text and watched the output pane update in real time; confirmed URL-encoding, then cycled to Base64 via tab and watched it re-encode the same input live; confirmed the active-transform highlighting via raw ANSI codes in the captured pane; fed invalid input to Base64 decode and confirmed the error placeholder renders instead of silently showing stale output; confirmed esc correctly backs out to the history list.
-rw-r--r--PLAN.md21
-rw-r--r--README.md13
-rw-r--r--cmd/mitmux/decoder.go150
-rw-r--r--cmd/mitmux/decoder_test.go85
-rw-r--r--cmd/mitmux/main.go75
5 files changed, 335 insertions, 9 deletions
diff --git a/PLAN.md b/PLAN.md
index 22ab6a4..67e0e77 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -99,13 +99,22 @@ anything but a narrow window, and unified reuses the same scrollable-
viewport pattern already used everywhere else in the TUI. CRLF is
normalized to LF before diffing (display-only, same reasoning as the
JSON pretty-printer) so an HTTP/1.1 exact capture doesn't show every
-line as changed from an invisible trailing \r.
+line as changed from an invisible trailing \r; a standalone Decoder
+tool ('d') - URL/Base64/Hex/HTML encode and decode, live output as you
+type, tab to cycle transforms. Deliberately single-transform, not
+chained/pipelined like Burp's Decoder - v1 scope, and pipeline-building
+UI is real added complexity for a feature that's already useful without
+it. Base64 decode tries standard/URL-safe/padded/unpadded variants in
+turn rather than making the user pick, since real pasted data is as
+likely to be one as the other. URL encode/decode uses strict RFC 3986
+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": a standalone encoder/decoder
-utility, 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.
+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.
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 c3f2d92..9bc683e 100644
--- a/README.md
+++ b/README.md
@@ -50,6 +50,8 @@ list of what's deliberately not implemented (and why), see
`flagged:true`.
- **Comparer**: mark one entry (`c`), then `c` on a different entry to
see a colored unified diff of either side's request or response.
+- **Decoder**: standalone URL/Base64/Hex/HTML encode and decode (`d`),
+ output updates live as you type or switch transforms.
- **Vi-modal editing**: the raw request editors (Repeater, Intruder)
are real modal editors - normal mode by default, `i`/`a`/`o`/etc. to
insert, `hjkl`, `dd`/`yy`/`p`, word motions, `gg`/`G`. See
@@ -124,6 +126,7 @@ below is enough to get going.
| `i` | open in Intruder |
| `f` | toggle flag |
| `c` | mark for comparison - press `c` on another entry to diff |
+| `d` | Decoder |
| `/` | search |
| `m` | match-and-replace rules |
| `q` | quit |
@@ -144,6 +147,16 @@ style, `+`/`-` lines - of the two entries' requests or responses,
exact HTTP/1.1 capture doesn't show every line as changed purely from
the invisible `\r`.
+### Decoder
+
+Reachable with `d` from the history list - a standalone tool, not seeded
+from any entry. `i` to type or paste text; the output pane updates live
+as you type. `tab`/`shift+tab` cycles through URL, Base64, and Hex
+encode/decode and HTML entity encode/decode. Base64 decode tries the
+standard, URL-safe, padded, and unpadded variants in turn rather than
+requiring you to know which one you're looking at. Single-transform
+only - not chained/pipelined the way Burp's Decoder supports.
+
### Search syntax
Plain text searches headers and bodies on both sides of the exchange.
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>`, "&lt;script&gt;alert(&#34;x&#34;)&lt;/script&gt;"},
+ {"html decode", opHTMLDecode, "&lt;b&gt;", "<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()