diff --git a/internal/cmd/branch/connections/show.go b/internal/cmd/branch/connections/show.go index 10b700c0f..78f980fb7 100644 --- a/internal/cmd/branch/connections/show.go +++ b/internal/cmd/branch/connections/show.go @@ -217,7 +217,7 @@ func printHumanConnectionList(out io.Writer, list live.ConnectionList, topology writeHumanField(out, "database", conn.DatabaseName) } fmt.Fprintln(out, "query:") - fmt.Fprintln(out, conn.QueryText) + fmt.Fprintln(out, live.SanitizeMultilineDisplayText(conn.QueryText)) } } @@ -237,7 +237,7 @@ func vitessHumanFields(conn live.Connection) [][2]string { } func writeHumanField(out io.Writer, name, value string) { - fmt.Fprintf(out, "%-16s %s\n", name+":", value) + fmt.Fprintf(out, "%-16s %s\n", name+":", live.SanitizeDisplayText(value)) } func unreachableInstanceWarning(instances []live.InstanceMeta) string { diff --git a/internal/cmd/branch/connections/show_test.go b/internal/cmd/branch/connections/show_test.go index 6ca9cbf49..0b75ffabb 100644 --- a/internal/cmd/branch/connections/show_test.go +++ b/internal/cmd/branch/connections/show_test.go @@ -119,6 +119,36 @@ func TestPrintHumanConnectionListUsesVerticalRecordsWithoutTruncatingQueryText(t c.Assert(got, qt.Contains, "query:\n"+longQuery+"\n") } +func TestPrintHumanConnectionListStripsTerminalEscapes(t *testing.T) { + c := qt.New(t) + var out bytes.Buffer + + printHumanConnectionList(&out, live.ConnectionList{ + CapturedAt: time.Date(2026, 4, 29, 12, 34, 56, 0, time.UTC), + Connections: []live.Connection{ + { + PID: 42, + Instance: "primary", + InstanceRole: "primary", + State: "active", + Username: "alice\x1b[31m", + ApplicationName: "\x1b]52;c;YWJj\x07app", + ClientAddr: "10.0.0.1\rSPOOF", + QueryText: "SELECT 1\x1b]8;;https://evil.example\x07\nFROM t", + }, + }, + }, ListTopology{}) + + got := out.String() + c.Assert(got, qt.Not(qt.Contains), "\x1b") + c.Assert(got, qt.Not(qt.Contains), "\x07") + c.Assert(got, qt.Not(qt.Contains), "\r") + c.Assert(got, qt.Contains, "user: alice[31m\n") + c.Assert(got, qt.Contains, "application: ]52;c;YWJjapp\n") + c.Assert(got, qt.Contains, "client_addr: 10.0.0.1SPOOF\n") + c.Assert(got, qt.Contains, "query:\nSELECT 1]8;;https://evil.example\nFROM t\n") +} + func TestPrintListPostgresHumanUnchanged(t *testing.T) { c := qt.New(t) queryID := "primary-123-query" diff --git a/internal/connections/sanitize.go b/internal/connections/sanitize.go new file mode 100644 index 000000000..b52f9e9ea --- /dev/null +++ b/internal/connections/sanitize.go @@ -0,0 +1,34 @@ +package connections + +import "strings" + +// SanitizeDisplayText strips ASCII C0 controls, DEL, and C1 controls from text +// that will be written to a terminal. Connection observability fields such as +// query text, application_name, username, and client_addr can contain +// attacker-controlled escape sequences (CSI/OSC) from anyone with connect +// access to the observed branch. +func SanitizeDisplayText(value string) string { + return strings.Map(func(r rune) rune { + switch { + case r < 0x20, r == 0x7f: + return -1 + case r >= 0x80 && r <= 0x9f: + return -1 + default: + return r + } + }, value) +} + +// SanitizeMultilineDisplayText sanitizes each line of value while preserving +// newline separators so multi-line query text stays readable in human output. +func SanitizeMultilineDisplayText(value string) string { + if value == "" || !strings.Contains(value, "\n") { + return SanitizeDisplayText(value) + } + lines := strings.Split(value, "\n") + for i, line := range lines { + lines[i] = SanitizeDisplayText(line) + } + return strings.Join(lines, "\n") +} diff --git a/internal/connections/sanitize_test.go b/internal/connections/sanitize_test.go new file mode 100644 index 000000000..37e89c65c --- /dev/null +++ b/internal/connections/sanitize_test.go @@ -0,0 +1,69 @@ +package connections + +import ( + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestSanitizeDisplayTextStripsControlsAndEscapes(t *testing.T) { + c := qt.New(t) + + cases := map[string]struct { + in string + want string + }{ + "plain text unchanged": { + in: "psql", + want: "psql", + }, + "CSI color sequence": { + in: "evil\x1b[31mred\x1b[0m", + want: "evil[31mred[0m", + }, + "OSC-8 hyperlink": { + in: "\x1b]8;;https://evil.example\x07label\x1b]8;;\x07", + want: "]8;;https://evil.examplelabel]8;;", + }, + "OSC-52 clipboard": { + in: "\x1b]52;c;YWJj\x07", + want: "]52;c;YWJj", + }, + "BEL and CR overwrite": { + in: "hide\rshown\x07", + want: "hideshown", + }, + "embedded newline field spoof": { + in: "app\npid: 1", + want: "apppid: 1", + }, + "C1 CSI single-byte": { + in: "x\u009b31my", + want: "x31my", + }, + "DEL": { + in: "a\x7fb", + want: "ab", + }, + "empty": { + in: "", + want: "", + }, + } + + for name, tc := range cases { + c.Run(name, func(c *qt.C) { + c.Assert(SanitizeDisplayText(tc.in), qt.Equals, tc.want) + }) + } +} + +func TestSanitizeMultilineDisplayTextPreservesNewlines(t *testing.T) { + c := qt.New(t) + + in := "select 1\x1b[31m\nfrom t\r\nwhere id = 1" + want := "select 1[31m\nfrom t\nwhere id = 1" + c.Assert(SanitizeMultilineDisplayText(in), qt.Equals, want) + c.Assert(SanitizeMultilineDisplayText("one line\x1b[0m"), qt.Equals, "one line[0m") + c.Assert(SanitizeMultilineDisplayText(""), qt.Equals, "") +} diff --git a/internal/connections/tui/blocking_graph_test.go b/internal/connections/tui/blocking_graph_test.go index 0247ab540..9d462ed3f 100644 --- a/internal/connections/tui/blocking_graph_test.go +++ b/internal/connections/tui/blocking_graph_test.go @@ -136,3 +136,21 @@ func TestBlockerLabelDescribesAbsentAndCycleRows(t *testing.T) { c.Assert(strings.Contains(label, "…"), qt.IsTrue) c.Assert(strings.Contains(label, "qa-block-holde "), qt.IsFalse) } + +func TestBlockerLabelStripsTerminalEscapes(t *testing.T) { + c := qt.New(t) + label := blockerLabel(blockerRow{ + PID: 42, + Present: true, + Connection: live.Connection{ + ApplicationName: "\x1b]52;c;YWJj\x07holder", + State: "active", + QueryText: "SELECT 1\x1b[31m", + }, + }) + c.Assert(label, qt.Not(qt.Contains), "\x1b") + c.Assert(label, qt.Not(qt.Contains), "\x07") + c.Assert(stripANSI(label), qt.Contains, "]52;c;YWJjhol") + c.Assert(stripANSI(label), qt.Contains, "SELECT 1[31m") + c.Assert(stripANSI(label), qt.Not(qt.Contains), "holder") // truncated in the APP column +} diff --git a/internal/connections/tui/detail.go b/internal/connections/tui/detail.go index c7eebdd28..02d854cf2 100644 --- a/internal/connections/tui/detail.go +++ b/internal/connections/tui/detail.go @@ -187,6 +187,7 @@ func renderConnectionRecord(fields [][2]string, query string, width int) []strin } func detailFieldValueLines(value string, width int) []string { + value = sanitizeFooterText(value) rendered := detailFieldValue(value) if strings.TrimSpace(value) == "" || width <= 0 || ansi.StringWidth(rendered) <= width { return []string{rendered} diff --git a/internal/connections/tui/detail_test.go b/internal/connections/tui/detail_test.go index a22a53c04..a41e82c12 100644 --- a/internal/connections/tui/detail_test.go +++ b/internal/connections/tui/detail_test.go @@ -631,6 +631,32 @@ func TestDetailQueryTabRendersProcesslistRecord(t *testing.T) { c.Assert(stripped, qt.Not(qt.Contains), "transaction_id:") } +func TestDetailRecordAndStatusStripTerminalEscapes(t *testing.T) { + c := qt.New(t) + lines := connectionRecordLines(live.Connection{ + PID: 5, + Instance: "primary", + Username: "bob\x1b[31m", + ApplicationName: "\x1b]52;c;YWJj\x07psql", + ClientAddr: "127.0.0.1\rX", + QueryText: "SELECT 1\x1b]8;;https://evil.example\x07", + }, connectionDisplayDefault, 120) + joined := strings.Join(lines, "\n") + c.Assert(joined, qt.Not(qt.Contains), "\x1b") + c.Assert(joined, qt.Not(qt.Contains), "\x07") + c.Assert(joined, qt.Not(qt.Contains), "\r") + c.Assert(joined, qt.Contains, "bob[31m") + c.Assert(joined, qt.Contains, "]52;c;YWJjpsql") + c.Assert(joined, qt.Contains, "127.0.0.1X") + + status := renderConnectionStatus("→ selected connection", live.Connection{ + PID: 5, + QueryText: "SELECT 1\x1b]52;c;YWJj\x07", + }, 120) + c.Assert(status, qt.Contains, "SELECT 1]52;c;YWJj") + c.Assert(status, qt.Not(qt.Contains), "\x1b") +} + func TestDetailProcesslistZeroDurationShowsNone(t *testing.T) { c := qt.New(t) view := stripANSI(renderDetail(detailState{ diff --git a/internal/connections/tui/table.go b/internal/connections/tui/table.go index 1891caa60..bea5d19e6 100644 --- a/internal/connections/tui/table.go +++ b/internal/connections/tui/table.go @@ -709,7 +709,7 @@ func renderSelectedStatus(state tableState) string { conn := state.List.Connections[state.Selected] status := fmt.Sprintf("selected pid %d", conn.PID) - query := strings.Join(strings.Fields(conn.QueryText), " ") + query := strings.Join(strings.Fields(sanitizeFooterText(conn.QueryText)), " ") if query != "" { status += " | " + query } @@ -718,7 +718,7 @@ func renderSelectedStatus(state tableState) string { // queryPreview returns the collapsed query text shown in the final table column. func queryPreview(query string) string { - collapsed := strings.Join(strings.Fields(query), " ") + collapsed := strings.Join(strings.Fields(sanitizeFooterText(query)), " ") return truncateRunes(collapsed, queryPreviewLimit) } @@ -1031,6 +1031,7 @@ func appText(name string) string { } func appTextForWidth(name string, width int) string { + name = sanitizeFooterText(name) if name == "" { return "-" } @@ -1124,6 +1125,7 @@ func formatTime(t *time.Time) string { } func emptyDash(value string) string { + value = sanitizeFooterText(value) if strings.TrimSpace(value) == "" { return "-" } @@ -1317,10 +1319,5 @@ func truncateRunes(value string, limit int) string { } func sanitizeFooterText(value string) string { - return strings.Map(func(r rune) rune { - if r < 0x20 || r == 0x7f { - return -1 - } - return r - }, value) + return live.SanitizeDisplayText(value) } diff --git a/internal/connections/tui/table_test.go b/internal/connections/tui/table_test.go index 3b6cb95df..70092cc22 100644 --- a/internal/connections/tui/table_test.go +++ b/internal/connections/tui/table_test.go @@ -559,6 +559,47 @@ func TestAppTextRightTruncatesLongNames(t *testing.T) { c.Assert(appText("owner_writer_226"), qt.Equals, "owner_writer_2…") c.Assert(appText("interactive_client_47"), qt.Equals, "interactive_c…") c.Assert(appText("xxxxxxxxxxxxxx"), qt.Equals, "xxxxxxxxxxxxxx") + c.Assert(appText("\x1b]52;c;YWJj\x07writer"), qt.Equals, "]52;c;YWJjwri…") +} + +func TestQueryPreviewAndSelectedStatusStripEscapes(t *testing.T) { + c := qt.New(t) + payload := "SELECT 1\x1b[31m FROM t\x1b]8;;https://evil.example\x07" + c.Assert(queryPreview(payload), qt.Equals, "SELECT 1[31m FROM t]8;;https://evil.example") + c.Assert(queryPreview(payload), qt.Not(qt.Contains), "\x1b") + + status := renderSelectedStatus(tableState{ + HasList: true, + Selected: 0, + Width: 120, + List: live.ConnectionList{ + Connections: []live.Connection{{ + PID: 7, + QueryText: "SELECT pg_sleep(1)\x1b]52;c;YWJj\x07", + }}, + }, + }) + c.Assert(status, qt.Contains, "selected pid 7") + c.Assert(status, qt.Contains, "SELECT pg_sleep(1)]52;c;YWJj") + c.Assert(status, qt.Not(qt.Contains), "\x1b") + c.Assert(status, qt.Not(qt.Contains), "\x07") +} + +func TestBuildConnectionRowsSanitizesUntrustedCells(t *testing.T) { + c := qt.New(t) + headers, rows := buildConnectionRows([]live.Connection{{ + PID: 9, + InstanceRole: "primary", + State: "active", + ApplicationName: "app\x1b[0m", + QueryText: "SELECT 1\x1b]52;c;YWJj\x07", + }}, nil, 200, -1) + + appIdx := indexOf(headers, "APP") + queryIdx := indexOf(headers, "QUERY") + c.Assert(rows[0][appIdx], qt.Equals, "app[0m") + c.Assert(rows[0][queryIdx], qt.Equals, "SELECT 1]52;c;YWJj") + c.Assert(strings.Join(rows[0], " "), qt.Not(qt.Contains), "\x1b") } func TestBuildConnectionRowsMarksSelectedRow(t *testing.T) { @@ -986,6 +1027,8 @@ func TestEmptyDash(t *testing.T) { c.Assert(emptyDash(" \t "), qt.Equals, "-") c.Assert(emptyDash("app"), qt.Equals, "app") + c.Assert(emptyDash("\x1b[31mred\x1b[0m"), qt.Equals, "[31mred[0m") + c.Assert(emptyDash("\x1b]52;c;YWJj\x07"), qt.Equals, "]52;c;YWJj") } func TestClipLine(t *testing.T) {