srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/cmd
diff options
context:
space:
mode:
Diffstat (limited to 'cmd')
-rw-r--r--cmd/mitmux/decoder.go150
-rw-r--r--cmd/mitmux/decoder_test.go85
-rw-r--r--cmd/mitmux/main.go75
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>`, "&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()