Commit 037e004e54

037e004e54b745419d2f165cbde10fee0445e77c

parent: f513f70f9a

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-24 04:31 UTC

control: control bytes in user text never reach the terminal

Ref #254
internal/control/table.go +1
@@ -61,6 +61,7 @@ func (t *table) row(cs ...cell) {
6161 }
6262 now := termNow()
6363 for i := range cs {
64 cs[i].s = termSafe(cs[i].s)
6465 if cs[i].kind == kindAge {
6566 cs[i].s = relAge(cs[i].s, now)
6667 }
internal/control/term.go +27
@@ -46,6 +46,33 @@ const (
4646 sgrMagenta = "\x1b[35m"
4747)
4848
49// termSafe replaces the bytes a terminal would act on — ESC, the C0
50// controls but tab and newline, DEL, and the C1 controls — with U+FFFD,
51// so user text cannot move the cursor, set the clipboard (OSC 52) or
52// clear the screen. Terminal output only: plain output is unchanged.
53func termSafe(s string) string {
54 unsafe := func(r rune) bool {
55 return (r < 0x20 && r != '\t' && r != '\n') || (r >= 0x7f && r <= 0x9f)
56 }
57 if strings.IndexFunc(s, unsafe) < 0 {
58 return s
59 }
60 return strings.Map(func(r rune) rune {
61 if unsafe(r) {
62 return '\uFFFD'
63 }
64 return r
65 }, s)
66}
67
68// safe is termSafe at a terminal and s unchanged in plain output.
69func (t Term) safe(s string) string {
70 if t.Cols == 0 {
71 return s
72 }
73 return termSafe(s)
74}
75
4976// paint wraps s in an SGR sequence when colour is on.
5077func (t Term) paint(sgr, s string) string {
5178 if !t.Color || sgr == "" || s == "" {
internal/control/termsafe_test.go added +70
@@ -0,0 +1,70 @@
1package control
2
3import (
4 "bytes"
5 "strings"
6 "testing"
7
8 "gitbay.org/gitbay/internal/protocol"
9 "gitbay.org/gitbay/internal/store"
10)
11
12const evil = "x\x1b]52;c;ZXZpbA==\x07y\x1b[2Jz"
13
14func TestTermSafe(t *testing.T) {
15 cases := map[string]string{
16 "plain\ttext\n": "plain\ttext\n",
17 "a\x1bb": "a�b",
18 "a\rb\x00c\x7fd": "a�b�c�d",
19 "a\u0085b\u009bc": "a�b�c",
20 "ümlaut": "ümlaut",
21 }
22 for in, want := range cases {
23 if got := termSafe(in); got != want {
24 t.Errorf("termSafe(%q) = %q, want %q", in, got, want)
25 }
26 }
27}
28
29func noControls(t *testing.T, what, out string) {
30 t.Helper()
31 s := stripSGR(out)
32 if strings.ContainsAny(s, "\x1b\x07") {
33 t.Errorf("%s: control bytes reach the terminal: %q", what, s)
34 }
35}
36
37func TestTableRowControlBytes(t *testing.T) {
38 var plain, term bytes.Buffer
39 for _, c := range []struct {
40 ctx *Ctx
41 w *bytes.Buffer
42 }{{&Ctx{}, &plain}, {&Ctx{Term: Term{Cols: 80, Color: true}}, &term}} {
43 tb := c.ctx.table(c.w, "#", "STATE", "TITLE")
44 tb.row(cRef("#1"), cState("open"), cFlex(evil))
45 tb.flush()
46 }
47 noControls(t, "table", term.String())
48 if want := "#1\topen\t" + evil + "\n"; plain.String() != want {
49 t.Errorf("plain changed: %q", plain.String())
50 }
51}
52
53func TestIssueShowControlBytes(t *testing.T) {
54 show := func(term Term) string {
55 st, repo, uid := newQueueTestRepo(t)
56 if _, err := st.CreateIssue(repo.ID, uid, evil, "body "+evil, "md"); err != nil {
57 t.Fatal(err)
58 }
59 c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid, Username: "alice"})
60 c.Term = term
61 if code := Dispatch(c, []string{"issue", "show", repo.Path(), "1"}); code != protocol.ExitOK {
62 t.Fatalf("exit %d: %s", code, errOut)
63 }
64 return c.Stdout.(*bytes.Buffer).String()
65 }
66 noControls(t, "issue show", show(Term{Cols: 80, Color: true}))
67 if out := show(Term{}); !strings.Contains(out, "#1 "+evil+" open\n") {
68 t.Errorf("plain title changed:\n%q", out)
69 }
70}
internal/control/view.go +7 −4
@@ -70,6 +70,7 @@ func (v *view) section(label string) {
7070func (v *view) title(ref, title, state string) {
7171 v.sep()
7272 t := v.c.Term
73 ref, title, state = t.safe(ref), t.safe(title), t.safe(state)
7374 if t.Cols == 0 {
7475 switch {
7576 case title == "" && state == "":
@@ -134,7 +135,7 @@ func (v *view) fields(kv ...string) {
134135 }
135136 v.sep()
136137 for i := 0; i+1 < len(kv); i += 2 {
137 key, val := kv[i], kv[i+1]
138 key, val := v.c.Term.safe(kv[i]), v.c.Term.safe(kv[i+1])
138139 if val == "" {
139140 continue
140141 }
@@ -159,6 +160,7 @@ func (v *view) body(src, format string) {
159160 return
160161 }
161162 v.sep()
163 src = v.c.Term.safe(src)
162164 for _, line := range strings.Split(strings.TrimRight(termtext.Render(src, format, v.opts()), "\n"), "\n") {
163165 if line == "" {
164166 io.WriteString(v.w, "\n")
@@ -171,8 +173,8 @@ func (v *view) body(src, format string) {
171173// event is one line for a system comment: its text without link
172174// targets, the time at the right edge at a terminal.
173175func (v *view) event(text, format, ts string) {
174 line := "· " + termtext.Inline(text, format)
175 when := v.c.when(ts)
176 line := "· " + termtext.Inline(v.c.Term.safe(text), format)
177 when := v.c.Term.safe(v.c.when(ts))
176178 if cols := v.c.Term.Cols; cols > 0 {
177179 room := cols - 2 - 2 - cells(when)
178180 line = pad(clip(line, room), room)
@@ -182,7 +184,8 @@ func (v *view) event(text, format, ts string) {
182184}
183185
184186func (v *view) comment(author, ts, body, format string) {
185 when := v.c.when(ts)
187 when := v.c.Term.safe(v.c.when(ts))
188 author = v.c.Term.safe(author)
186189 cols := v.c.Term.Cols
187190 if cols > 0 {
188191 suffix := ", " + when + " "
internal/termtext/termtext.go +2
@@ -20,6 +20,8 @@ type Options struct {
2020 Base string
2121}
2222
23// Render lays out markdown or org for a terminal. It passes control
24// bytes in src through: callers sanitise src first (control.termSafe).
2325func Render(src, format string, o Options) string {
2426 if format == "org" {
2527 return Org(src, o)