From 157dd0a91badb7eabb3e670b9656f8471dfc9eb6 Mon Sep 17 00:00:00 2001 From: srdusr <99972264+srdusr@users.noreply.github.com> Date: Fri, 6 Feb 2026 09:28:00 +0200 Subject: Response JSON pretty-printing (display-only) Another item off the Burp/ZAP/Caido gap list: reading raw JSON responses without any formatting is real daily friction. pretty.go parses raw response bytes via net/http (reusing its tested chunked-transfer-encoding and gzip content-encoding handling rather than reimplementing either) and, if the decoded body is valid JSON, returns it indented. Framing headers that no longer describe the reformatted body (Transfer-Encoding, Content-Encoding, Content-Length) are dropped from the displayed header block since keeping them would be actively misleading. Falls back to raw on anything that doesn't parse cleanly. This is deliberately display-only and off by default: 'p' toggles it in the detail view's response tab, refreshing the viewport in place; the underlying raw bytes (what's stored, what would be resent) are never touched. Not wired into Repeater's response pane or into either tool's editable request buffer - the whole point of this tool is byte- exact control, so nothing that could be sent anywhere gets silently reformatted, only a read-only view a user explicitly asked to reformat. Verified live against a real response with a known formatting quirk: httpbin.org's own JSON output uses Python's json.dumps with ", " separators, leaving a trailing space before each newline (confirmed directly in the stored raw bytes: "7B 7D 2C 20 0A" - "{}, \n"). Toggling pretty mode replaced it with Go's canonical json.Indent output, and toggling back returned the original raw bytes - proving the reformatting is real, not just passing through the origin's own formatting. --- cmd/mitmux/main.go | 36 +++++++++++++++++++---- cmd/mitmux/pretty.go | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 6 deletions(-) create mode 100644 cmd/mitmux/pretty.go diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go index 55ab263..360b68d 100644 --- a/cmd/mitmux/main.go +++ b/cmd/mitmux/main.go @@ -122,9 +122,10 @@ type model struct { query string // applied filter, "" means unfiltered pendingNew int // live entries captured while a filter hides them - viewport viewport.Model - detail *ipc.EntryDetail - activeTab detailTab + viewport viewport.Model + detail *ipc.EntryDetail + activeTab detailTab + prettyMode bool // display-only JSON reformatting of the response body reqArea viTextarea respView viewport.Model @@ -577,7 +578,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.detail = msg.detail m.activeTab = tabRequest - m.viewport.SetContent(detailBody(m.detail, m.activeTab)) + m.viewport.SetContent(m.detailContent()) m.viewport.GotoTop() return m, nil @@ -722,6 +723,12 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.enterIntruder(m.detail) } return m, nil + case "p": + m.prettyMode = !m.prettyMode + if m.detail != nil { + m.viewport.SetContent(m.detailContent()) + } + return m, nil case "tab": if m.activeTab == tabRequest { m.activeTab = tabResponse @@ -729,7 +736,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.activeTab = tabRequest } if m.detail != nil { - m.viewport.SetContent(detailBody(m.detail, m.activeTab)) + m.viewport.SetContent(m.detailContent()) m.viewport.GotoTop() } return m, nil @@ -1023,6 +1030,7 @@ func (m *model) helpView() string { section("Detail view", "tab switch request/response", "↑/↓ or j/k scroll (also g/G, ctrl+u/d - same as history list)", + "p toggle pretty-printed JSON (response only, display-only)", "r / i open in Repeater / Intruder", "esc / q back to history", ) @@ -1097,6 +1105,9 @@ func (m *model) detailView() string { reqLabel := fmt.Sprintf("Request (%d bytes%s)", len(m.detail.RequestRaw), exactSuffix(m.detail.RequestExact)) respLabel := fmt.Sprintf("Response (%d bytes%s)", len(m.detail.ResponseRaw), exactSuffix(m.detail.ResponseExact)) + if m.prettyMode { + respLabel += " [pretty]" + } if m.activeTab == tabRequest { b.WriteString(tabActive.Render(reqLabel)) b.WriteString(tabInactive.Render(respLabel)) @@ -1107,7 +1118,7 @@ func (m *model) detailView() string { b.WriteString("\n") b.WriteString(m.viewport.View()) b.WriteString("\n") - b.WriteString(helpStyle.Render("tab switch · ↑/↓ scroll · r repeater · i intruder · esc back · q quit")) + b.WriteString(helpStyle.Render("tab switch · p pretty-print JSON · ↑/↓ scroll · r repeater · i intruder · esc back · q quit")) return b.String() } @@ -1293,6 +1304,19 @@ func exactSuffix(exact bool) string { return ", reconstructed" } +// detailContent is what actually gets shown in the detail viewport: +// the raw response, or - when prettyMode is on and the body is JSON - +// an indented rendering of it. Always falls back to raw on anything +// that doesn't parse cleanly; the underlying bytes are never touched. +func (m *model) detailContent() string { + if m.prettyMode && m.activeTab == tabResponse && m.detail != nil { + if pretty, ok := prettyResponse(m.detail.ResponseRaw); ok { + return pretty + } + } + return detailBody(m.detail, m.activeTab) +} + func detailBody(d *ipc.EntryDetail, tab detailTab) string { if d == nil { return "" diff --git a/cmd/mitmux/pretty.go b/cmd/mitmux/pretty.go new file mode 100644 index 0000000..ede6809 --- /dev/null +++ b/cmd/mitmux/pretty.go @@ -0,0 +1,80 @@ +package main + +import ( + "bufio" + "bytes" + "compress/gzip" + "encoding/json" + "io" + "net/http" + "sort" +) + +// maxPrettyBytes bounds how much of a body gets loaded into memory just +// to reformat it for reading - a display convenience, not the capture +// path, so it doesn't need (and shouldn't risk) unbounded memory use. +const maxPrettyBytes = 5 << 20 // 5 MiB + +// prettyResponse parses raw as an HTTP response and, if its body is +// JSON, returns a version with the body indented for reading - chunked +// transfer-encoding and gzip content-encoding are both handled via +// net/http's own tested parsing rather than reimplemented here. This is +// display-only: the caller always keeps the original raw bytes as what +// was actually received/sent, and only substitutes this rendering into +// a read-only view. Framing headers (Transfer-Encoding, Content-Encoding, +// Content-Length) are dropped from the displayed header block since they +// no longer describe the reformatted body and would be misleading. +// Returns ok=false whenever formatting can't be done safely, in which +// case the caller should show the raw bytes unchanged. +func prettyResponse(raw []byte) (formatted string, ok bool) { + resp, err := http.ReadResponse(bufio.NewReader(bytes.NewReader(raw)), nil) + if err != nil { + return "", false + } + defer resp.Body.Close() + + var reader io.Reader = resp.Body + if resp.Header.Get("Content-Encoding") == "gzip" { + gz, err := gzip.NewReader(resp.Body) + if err != nil { + return "", false + } + defer gz.Close() + reader = gz + } + + body, err := io.ReadAll(io.LimitReader(reader, maxPrettyBytes)) + if err != nil || !json.Valid(body) { + return "", false + } + var pretty bytes.Buffer + if err := json.Indent(&pretty, body, "", " "); err != nil { + return "", false + } + + var out bytes.Buffer + out.WriteString(resp.Proto + " " + resp.Status + "\r\n") + writeHeadersSorted(&out, resp.Header, "Transfer-Encoding", "Content-Encoding", "Content-Length") + out.WriteString("\r\n") + out.Write(pretty.Bytes()) + return out.String(), true +} + +func writeHeadersSorted(out *bytes.Buffer, h http.Header, omit ...string) { + skip := make(map[string]bool, len(omit)) + for _, k := range omit { + skip[http.CanonicalHeaderKey(k)] = true + } + keys := make([]string, 0, len(h)) + for k := range h { + if !skip[k] { + keys = append(keys, k) + } + } + sort.Strings(keys) + for _, k := range keys { + for _, v := range h[k] { + out.WriteString(k + ": " + v + "\r\n") + } + } +} -- cgit v1.2.3