Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions internal/cmd/branch/connections/show.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}

Expand All @@ -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 {
Expand Down
30 changes: 30 additions & 0 deletions internal/cmd/branch/connections/show_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
34 changes: 34 additions & 0 deletions internal/connections/sanitize.go
Original file line number Diff line number Diff line change
@@ -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")
}
69 changes: 69 additions & 0 deletions internal/connections/sanitize_test.go
Original file line number Diff line number Diff line change
@@ -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, "")
}
18 changes: 18 additions & 0 deletions internal/connections/tui/blocking_graph_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
1 change: 1 addition & 0 deletions internal/connections/tui/detail.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
26 changes: 26 additions & 0 deletions internal/connections/tui/detail_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
13 changes: 5 additions & 8 deletions internal/connections/tui/table.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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)
}

Expand Down Expand Up @@ -1031,6 +1031,7 @@ func appText(name string) string {
}

func appTextForWidth(name string, width int) string {
name = sanitizeFooterText(name)
if name == "" {
return "-"
}
Expand Down Expand Up @@ -1124,6 +1125,7 @@ func formatTime(t *time.Time) string {
}

func emptyDash(value string) string {
value = sanitizeFooterText(value)
if strings.TrimSpace(value) == "" {
return "-"
}
Expand Down Expand Up @@ -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)
}
43 changes: 43 additions & 0 deletions internal/connections/tui/table_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down