srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2026-04-13 01:37:00 +0200
committersrdusr <[email protected]>2026-04-13 01:37:00 +0200
commitcecf6f0ea3d5e4706c134f708c50b00c519d536f (patch)
tree1b167aa450e598b9f78fc4e472310ea563e15eda
parent7b9bf73e46102969d5802300469862296f979ef6 (diff)
downloadmitmux-cecf6f0ea3d5e4706c134f708c50b00c519d536f.tar.gz
mitmux-cecf6f0ea3d5e4706c134f708c50b00c519d536f.zip
Fix terminal-injection and table-cursor-desync bugs found by audit
Two robustness fixes from a hands-on edge-case audit of the TUI (driving the app in tmux against a daemon fed adversarial history data, not just code review). 1. Control-character/ANSI injection from captured traffic reaches the real terminal. mitmux exists specifically to MITM hostile servers, but every rendered string (table cells, detail/repeater/comparer text, decoder output) was written straight to stdout via lipgloss with no escaping. Confirmed live before the fix: a history entry whose Host contained an OSC title-change sequence changed the actual tmux pane title; a Path containing a clear-screen CSI sequence corrupted the TUI's own rendering. Fixed with sanitizeControl (cmd/mitmux/sanitize.go): replaces ASCII control bytes with "." before display, preserving \n/\t in multi-line contexts (sanitizeBlock) and stripping everything including those in single-line contexts like table cells and titles (sanitizeLine). Display-only, same pattern as the existing CRLF-normalization and JSON-pretty-printing transforms - never touches stored bytes or what Repeater/Intruder actually send. Applied at every render boundary: history table rows, detail/repeater/comparer titles and body text, Intruder's grep-extract column (text pulled directly out of an attacker-controlled response via regex), decoder output, and status messages. The one deliberate trade-off: Repeater/Intruder's editable template buffers are seeded with sanitized text too (otherwise opening a captured request with raw escape bytes would corrupt the editor's own rendering just by being viewed) - resending it unmodified sends the sanitized text; typing the original control bytes back in still sends them verbatim, since only the seed is sanitized, not live keystrokes. Verified live: re-tested the exact repro (OSC/CSI bytes in Host/Path/response body) post-fix - renders as literal "." in place of each control byte, zero corruption, zero title change. 2. Table cursor desync when a live entry arrives on an empty list. bubbles/table's own SetRows only clamps the cursor's *upper* bound (cursor > len(rows)-1) - on an empty table the cursor sits at -1, and going from 0 rows to N rows never re-clamps that lower bound, so every "row >= 0 && row < len(...)" guard (enter/r/i/f/c) silently no-ops until the next navigation keypress happens to clamp it. Confirmed live: a live-captured entry arriving while history was still empty left every action on it inert - pressing enter did nothing - until an arrow key was pressed first, with zero error or feedback. Fixed with setTableRows (calls SetRows then SetCursor(Cursor()), the latter clamping both bounds correctly), replacing every direct .SetRows() call across all three tables (history, rules, intruder results) so the same fix covers all of them, not just the one the audit happened to catch. Verified live: fresh empty-history daemon, TUI already running, captured a request via curl, pressed enter immediately with no prior navigation - opened detail view correctly. Both found via two parallel fork audits (backend and frontend) that actually drove the daemon/TUI against adversarial input rather than reading code; a third backend-focused round of fixes follows separately. go build/vet/gofmt/test/mod tidy all clean.
-rw-r--r--cmd/mitmux/compare.go12
-rw-r--r--cmd/mitmux/decoder.go8
-rw-r--r--cmd/mitmux/main.go89
-rw-r--r--cmd/mitmux/pretty.go5
-rw-r--r--cmd/mitmux/sanitize.go50
-rw-r--r--cmd/mitmux/sanitize_test.go47
6 files changed, 177 insertions, 34 deletions
diff --git a/cmd/mitmux/compare.go b/cmd/mitmux/compare.go
index 34bb684..84a1930 100644
--- a/cmd/mitmux/compare.go
+++ b/cmd/mitmux/compare.go
@@ -71,7 +71,11 @@ func (m *model) compareContent() string {
} else {
aText, bText = string(m.compareA.ResponseRaw), string(m.compareB.ResponseRaw)
}
- return unifiedDiff(fmt.Sprintf("#%d", m.compareA.ID), fmt.Sprintf("#%d", m.compareB.ID), aText, bText)
+ // Sanitized before diffing (display-only, same as the CRLF
+ // normalization already happening inside unifiedDiff) - otherwise a
+ // captured entry with control-character/ANSI injection would corrupt
+ // the comparer's own rendering just by being diffed. See sanitize.go.
+ return unifiedDiff(fmt.Sprintf("#%d", m.compareA.ID), fmt.Sprintf("#%d", m.compareB.ID), sanitizeBlock(aText), sanitizeBlock(bText))
}
func (m *model) compareView() string {
@@ -81,8 +85,8 @@ func (m *model) compareView() string {
return b.String()
}
title := fmt.Sprintf(" comparer - #%d (%s %s%s -> %d) vs #%d (%s %s%s -> %d) ",
- m.compareA.ID, m.compareA.Method, m.compareA.Host, m.compareA.Path, m.compareA.StatusCode,
- m.compareB.ID, m.compareB.Method, m.compareB.Host, m.compareB.Path, m.compareB.StatusCode)
+ m.compareA.ID, sanitizeLine(m.compareA.Method), sanitizeLine(m.compareA.Host), sanitizeLine(m.compareA.Path), m.compareA.StatusCode,
+ m.compareB.ID, sanitizeLine(m.compareB.Method), sanitizeLine(m.compareB.Host), sanitizeLine(m.compareB.Path), m.compareB.StatusCode)
b.WriteString(titleStyle.Render(title))
b.WriteString("\n")
@@ -97,7 +101,7 @@ func (m *model) compareView() string {
b.WriteString(m.compareViewport.View())
b.WriteString("\n")
if m.statusMsg != "" {
- b.WriteString(statusStyle.Render(m.statusMsg))
+ b.WriteString(statusStyle.Render(sanitizeLine(m.statusMsg)))
b.WriteString("\n")
}
b.WriteString(helpStyle.Render("tab switch request/response · ↑/↓ scroll · esc back · q quit"))
diff --git a/cmd/mitmux/decoder.go b/cmd/mitmux/decoder.go
index aebb647..69416a5 100644
--- a/cmd/mitmux/decoder.go
+++ b/cmd/mitmux/decoder.go
@@ -118,7 +118,11 @@ func decodeBase64Lenient(s string) ([]byte, error) {
}
func (m *model) decoderContent() string {
- return applyDecoderOp(m.decoderOp, m.decoderInput.Value())
+ // Sanitized (see sanitize.go): a decode operation on pasted-in
+ // content (e.g. hex or Base64 copied from a captured response) can
+ // legitimately produce raw control bytes, which would otherwise
+ // reach the terminal via this output pane.
+ return sanitizeBlock(applyDecoderOp(m.decoderOp, m.decoderInput.Value()))
}
func (m *model) decoderView() string {
@@ -142,7 +146,7 @@ func (m *model) decoderView() string {
b.WriteString(viModeLabel(&m.decoderInput))
b.WriteString("\n")
if m.statusMsg != "" {
- b.WriteString(statusStyle.Render(m.statusMsg))
+ b.WriteString(statusStyle.Render(sanitizeLine(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"))
diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go
index 1d6d3c5..75f4410 100644
--- a/cmd/mitmux/main.go
+++ b/cmd/mitmux/main.go
@@ -481,7 +481,15 @@ func (m *model) enterRepeater(d *ipc.EntryDetail) {
reqArea: newViTextarea(),
}
t.reqArea.ta.ShowLineNumbers = false
- t.reqArea.SetValue(strings.ReplaceAll(string(d.RequestRaw), "\r\n", "\n"))
+ // sanitizeBlock both normalizes CRLF->LF (the textarea only
+ // understands LF, same trade-off as sendRepeat's restore-on-send)
+ // and strips control-character/ANSI injection from the seeded text
+ // (see sanitize.go) - a captured request containing raw escape bytes
+ // would otherwise corrupt the editor's own rendering just by being
+ // opened. Narrow, deliberate trade-off: resending such a request
+ // unmodified via ctrl+r sends the sanitized text, not the original
+ // control bytes: type them back in directly to send them verbatim.
+ t.reqArea.SetValue(sanitizeBlock(string(d.RequestRaw)))
t.reqArea.Focus()
t.focus = focusRequest
m.sizeRepeaterTab(t)
@@ -497,11 +505,13 @@ func (m *model) enterRepeater(d *ipc.EntryDetail) {
func (m *model) enterIntruder(d *ipc.EntryDetail) {
m.intruderScheme = d.Scheme
m.intruderHost = d.Host
- m.intruderTemplate.SetValue(strings.ReplaceAll(string(d.RequestRaw), "\r\n", "\n"))
+ // See enterRepeater's identical seeding for why sanitizeBlock (not
+ // just CRLF normalization) is used here.
+ m.intruderTemplate.SetValue(sanitizeBlock(string(d.RequestRaw)))
m.intruderTemplate.Focus()
m.intruderPayloads.Blur()
m.intruderRows = nil
- m.intruderResults.SetRows(nil)
+ setTableRows(&m.intruderResults, nil)
m.intruderFocus = focusTemplate
m.intruderRunning = false
m.intruderCount = 0
@@ -705,7 +715,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
m.entries = msg.entries
- m.table.SetRows(rowsFor(m.entries))
+ setTableRows(&m.table, rowsFor(m.entries))
return m, nil
case newEntryMsg:
@@ -725,7 +735,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.waitForEntry
}
m.entries = append([]store.Summary{msg.entry}, m.entries...)
- m.table.SetRows(rowsFor(m.entries))
+ setTableRows(&m.table, rowsFor(m.entries))
return m, m.waitForEntry
case statusLoadedMsg:
@@ -801,7 +811,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
m.ruleRows = msg.rules
- m.rulesTable.SetRows(rulesRowsFor(m.ruleRows))
+ setTableRows(&m.rulesTable, rulesRowsFor(m.ruleRows))
return m, nil
case ruleWriteDoneMsg:
@@ -834,7 +844,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.intruderCount++
m.intruderRows = append(m.intruderRows, msg.result)
- m.intruderResults.SetRows(intrudeRowsFor(m.intruderRows))
+ setTableRows(&m.intruderResults, intrudeRowsFor(m.intruderRows))
return m, m.waitForIntrudeResult
case tea.KeyMsg:
@@ -885,7 +895,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "f":
if row := m.table.Cursor(); row >= 0 && row < len(m.entries) {
m.entries[row].Flagged = !m.entries[row].Flagged
- m.table.SetRows(rowsFor(m.entries))
+ setTableRows(&m.table, rowsFor(m.entries))
return m, m.setFlagged(m.entries[row].ID, m.entries[row].Flagged)
}
case "c":
@@ -1497,7 +1507,7 @@ func (m *model) listView() string {
b.WriteString(m.table.View())
b.WriteString("\n")
if m.statusMsg != "" {
- b.WriteString(statusStyle.Render(m.statusMsg))
+ b.WriteString(statusStyle.Render(sanitizeLine(m.statusMsg)))
b.WriteString("\n")
}
help := "enter view · r/i/c/d tools · f flag · / search · m rules · ? help · q quit"
@@ -1518,7 +1528,7 @@ func (m *model) detailView() string {
if m.detail.Flagged {
flagMark = "★ "
}
- title := fmt.Sprintf(" %s#%d %s %s%s -> %d ", flagMark, m.detail.ID, m.detail.Method, m.detail.Host, m.detail.Path, m.detail.StatusCode)
+ title := fmt.Sprintf(" %s#%d %s %s%s -> %d ", flagMark, m.detail.ID, sanitizeLine(m.detail.Method), sanitizeLine(m.detail.Host), sanitizeLine(m.detail.Path), m.detail.StatusCode)
b.WriteString(titleStyle.Render(title))
b.WriteString("\n")
@@ -1553,7 +1563,7 @@ func (m *model) repeaterView() string {
if len(m.repeaterTabs) > 1 {
for i, rt := range m.repeaterTabs {
- label := fmt.Sprintf(" %d:%s ", i+1, rt.host)
+ label := fmt.Sprintf(" %d:%s ", i+1, sanitizeLine(rt.host))
if i == m.repeaterIndex {
b.WriteString(tabActive.Render(label))
} else {
@@ -1563,7 +1573,7 @@ func (m *model) repeaterView() string {
b.WriteString("\n")
}
- title := fmt.Sprintf(" repeater - %s://%s ", t.scheme, t.host)
+ title := fmt.Sprintf(" repeater - %s://%s ", sanitizeLine(t.scheme), sanitizeLine(t.host))
b.WriteString(titleStyle.Render(title))
b.WriteString("\n")
@@ -1589,7 +1599,7 @@ func (m *model) repeaterView() string {
b.WriteString("\n")
}
if m.statusMsg != "" {
- b.WriteString(statusStyle.Render(m.statusMsg))
+ b.WriteString(statusStyle.Render(sanitizeLine(m.statusMsg)))
b.WriteString("\n")
}
b.WriteString(helpStyle.Render("i to edit (vi keys) · ctrl+r send · tab switch pane · ]/[ next/prev tab · ctrl+w close tab · esc back · ? help · ctrl+c quit"))
@@ -1613,7 +1623,7 @@ func (m *model) rulesView() string {
b.WriteString(m.rulesTable.View())
b.WriteString("\n")
if m.statusMsg != "" {
- b.WriteString(statusStyle.Render(m.statusMsg))
+ b.WriteString(statusStyle.Render(sanitizeLine(m.statusMsg)))
b.WriteString("\n")
}
b.WriteString(helpStyle.Render("a add · enter/e edit · d delete · space toggle · esc back · q quit"))
@@ -1652,7 +1662,7 @@ func (m *model) ruleFormView() string {
b.WriteString(label(fieldRegex, regexText) + "\n\n")
if m.statusMsg != "" {
- b.WriteString(statusStyle.Render(m.statusMsg))
+ b.WriteString(statusStyle.Render(sanitizeLine(m.statusMsg)))
b.WriteString("\n")
}
b.WriteString(helpStyle.Render("tab/shift+tab move · ctrl+s save · esc cancel · ctrl+c quit"))
@@ -1677,9 +1687,9 @@ func rulesRowsFor(rs []rules.Rule) []table.Row {
func (m *model) intruderView() string {
var b strings.Builder
- title := fmt.Sprintf(" intruder - %s://%s ", m.intruderScheme, m.intruderHost)
+ title := fmt.Sprintf(" intruder - %s://%s ", sanitizeLine(m.intruderScheme), sanitizeLine(m.intruderHost))
if m.intruderRunning {
- title = fmt.Sprintf(" intruder - %s://%s (running, %d sent) ", m.intruderScheme, m.intruderHost, m.intruderCount)
+ title = fmt.Sprintf(" intruder - %s://%s (running, %d sent) ", sanitizeLine(m.intruderScheme), sanitizeLine(m.intruderHost), m.intruderCount)
}
b.WriteString(titleStyle.Render(title))
b.WriteString("\n")
@@ -1734,7 +1744,7 @@ func (m *model) intruderView() string {
b.WriteString("\n")
}
if m.statusMsg != "" {
- b.WriteString(statusStyle.Render(m.statusMsg))
+ b.WriteString(statusStyle.Render(sanitizeLine(m.statusMsg)))
b.WriteString("\n")
}
if m.grepEditing != 0 {
@@ -1758,13 +1768,16 @@ func intrudeRowsFor(rs []ipc.IntrudeResultMsg) []table.Row {
}
rows[i] = table.Row{
fmt.Sprintf("%d", r.Position),
- r.Payload,
+ sanitizeLine(r.Payload),
status,
humanBytes(r.RespSize),
r.Duration.Round(time.Millisecond).String(),
match,
- r.GrepExtract,
- r.Error,
+ // GrepExtract is pulled directly out of the (attacker-
+ // controlled) response body via a user regex - sanitize it
+ // same as everything else derived from captured traffic.
+ sanitizeLine(r.GrepExtract),
+ sanitizeLine(r.Error),
}
}
return rows
@@ -1790,6 +1803,10 @@ func (m *model) detailContent() string {
return detailBody(m.detail, m.activeTab)
}
+// detailBody renders raw request/response bytes as display text.
+// Sanitized against control-character/ANSI injection (see sanitize.go)
+// - this is what's shown, never what's stored or resent, so it stays
+// display-only like every other transform here.
func detailBody(d *ipc.EntryDetail, tab detailTab) string {
if d == nil {
return ""
@@ -1798,15 +1815,15 @@ func detailBody(d *ipc.EntryDetail, tab detailTab) string {
if len(d.RequestRaw) == 0 {
return "(empty)"
}
- return string(d.RequestRaw)
+ return sanitizeBlock(string(d.RequestRaw))
}
if len(d.ResponseRaw) == 0 {
if d.Error != "" {
- return "(no response - " + d.Error + ")"
+ return "(no response - " + sanitizeLine(d.Error) + ")"
}
return "(empty)"
}
- return string(d.ResponseRaw)
+ return sanitizeBlock(string(d.ResponseRaw))
}
func rowsFor(entries []store.Summary) []table.Row {
@@ -1824,9 +1841,9 @@ func rowsFor(entries []store.Summary) []table.Row {
rows[i] = table.Row{
flag,
fmt.Sprintf("%d", e.ID),
- e.Method,
- e.Host,
- e.Path,
+ sanitizeLine(e.Method),
+ sanitizeLine(e.Host),
+ sanitizeLine(e.Path),
status,
size,
e.Duration.Round(time.Millisecond).String(),
@@ -1835,6 +1852,24 @@ func rowsFor(entries []store.Summary) []table.Row {
return rows
}
+// setTableRows replaces t's rows and re-clamps the cursor afterward.
+// bubbles/table's own SetRows only clamps the cursor's upper bound
+// (cursor > len(rows)-1) - on an empty table the cursor sits at -1, and
+// going from 0 rows to N rows never re-clamps that lower bound, so the
+// cursor stays at -1 until the next navigation keypress. Every "row >=
+// 0 && row < len(...)" guard in this file (enter/r/i/f/c, everywhere)
+// then silently no-ops on a table that looks populated but has an
+// invalid selection - confirmed live: a live-captured entry arriving
+// while the history list was still empty left every action on it inert
+// until an arrow key was pressed first. SetCursor re-clamps both bounds
+// (see bubbles' clamp(n, 0, len(rows)-1)), so calling it after every
+// SetRows is a cheap, always-safe normalization - a no-op when the
+// cursor was already valid.
+func setTableRows(t *table.Model, rows []table.Row) {
+ t.SetRows(rows)
+ t.SetCursor(t.Cursor())
+}
+
func humanBytes(n int) string {
if n < 1024 {
return fmt.Sprintf("%dB", n)
diff --git a/cmd/mitmux/pretty.go b/cmd/mitmux/pretty.go
index ede6809..f6da6ad 100644
--- a/cmd/mitmux/pretty.go
+++ b/cmd/mitmux/pretty.go
@@ -57,7 +57,10 @@ func prettyResponse(raw []byte) (formatted string, ok bool) {
writeHeadersSorted(&out, resp.Header, "Transfer-Encoding", "Content-Encoding", "Content-Length")
out.WriteString("\r\n")
out.Write(pretty.Bytes())
- return out.String(), true
+ // Status and header values are server-controlled and not JSON-escaped
+ // like the body is - sanitize the whole rendering against control-
+ // character/ANSI injection before it's ever displayed (see sanitize.go).
+ return sanitizeBlock(out.String()), true
}
func writeHeadersSorted(out *bytes.Buffer, h http.Header, omit ...string) {
diff --git a/cmd/mitmux/sanitize.go b/cmd/mitmux/sanitize.go
new file mode 100644
index 0000000..8549d5c
--- /dev/null
+++ b/cmd/mitmux/sanitize.go
@@ -0,0 +1,50 @@
+package main
+
+import "strings"
+
+// sanitizeControl replaces ASCII control bytes with a literal "." before
+// captured content is displayed. mitmux exists specifically to MITM
+// hostile servers, and every rendered string in this TUI - table cells,
+// detail/repeater/comparer text, decoder output - is written straight to
+// stdout via lipgloss with no escaping of its own. A raw ANSI/OSC escape
+// sequence in a Host, Path, header, or body (or extracted from one via
+// Intruder's grep-extract) would reach the operator's actual terminal
+// and could rewrite its title, move the cursor, or clear the screen -
+// confirmed live: a Host containing an OSC title-change sequence changed
+// the real tmux pane title; a Path containing a clear-screen sequence
+// corrupted the TUI's own rendering. This is a display-only transform,
+// same as the CRLF/pretty-printing helpers elsewhere - it never touches
+// stored bytes or what Repeater/Intruder actually send, only what gets
+// written to the terminal.
+//
+// keepLF preserves '\n' and '\t' for multi-line raw-text views where
+// structure matters (request/response text, diffs, decoder output);
+// single-line contexts (table cells, title bars) should pass
+// keepLF=false. '\r' is always dropped rather than passed through raw -
+// CRLF is reconstructed at editor boundaries elsewhere, and a bare '\r'
+// has no legitimate display purpose but does move the cursor if written
+// as-is.
+func sanitizeControl(s string, keepLF bool) string {
+ var b strings.Builder
+ b.Grow(len(s))
+ for _, r := range s {
+ switch {
+ case (r == '\n' || r == '\t') && keepLF:
+ b.WriteRune(r)
+ case r == '\r':
+ case r < 0x20 || r == 0x7f:
+ b.WriteByte('.')
+ default:
+ b.WriteRune(r)
+ }
+ }
+ return b.String()
+}
+
+// sanitizeLine sanitizes a single-line display value (table cell, title,
+// label).
+func sanitizeLine(s string) string { return sanitizeControl(s, false) }
+
+// sanitizeBlock sanitizes a multi-line display value (raw request/
+// response text, diffs, decoder output), preserving line breaks.
+func sanitizeBlock(s string) string { return sanitizeControl(s, true) }
diff --git a/cmd/mitmux/sanitize_test.go b/cmd/mitmux/sanitize_test.go
new file mode 100644
index 0000000..a487413
--- /dev/null
+++ b/cmd/mitmux/sanitize_test.go
@@ -0,0 +1,47 @@
+package main
+
+import "testing"
+
+func TestSanitizeLine(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want string
+ }{
+ {"plain text unchanged", "example.com/path", "example.com/path"},
+ {"osc title change stripped", "evil\x1b]0;pwned\x07.com", "evil.]0;pwned..com"},
+ {"csi clear screen stripped", "\x1b[2J\x1b[Hpath", ".[2J.[Hpath"},
+ {"bell stripped", "a\x07b", "a.b"},
+ {"del stripped", "a\x7fb", "a.b"},
+ {"newline stripped in line mode", "a\nb", "a.b"},
+ {"cr always dropped", "a\rb", "ab"},
+ {"invalid utf-8 replaced not crashed", "a\xffb", "a�b"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := sanitizeLine(tt.input); got != tt.want {
+ t.Errorf("sanitizeLine(%q) = %q, want %q", tt.input, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestSanitizeBlock(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want string
+ }{
+ {"newline preserved", "line1\nline2", "line1\nline2"},
+ {"cr stripped, lf kept", "line1\r\nline2", "line1\nline2"},
+ {"escape stripped, structure kept", "GET /\x1b]0;x\x07 HTTP/1.1\nHost: x", "GET /.]0;x. HTTP/1.1\nHost: x"},
+ {"tab passes through", "a\tb", "a\tb"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := sanitizeBlock(tt.input); got != tt.want {
+ t.Errorf("sanitizeBlock(%q) = %q, want %q", tt.input, got, tt.want)
+ }
+ })
+ }
+}