detached 4bb15ab

fix(protocol): reject unsafe catalogue text

commit
4bb15ab67b7acb7ca0f6208892f411fdd415dc47
author
brice · · 2026-08-28 08:59 UTC
committer
brice · · 2026-08-28 14:56 UTC
parent
ca64e93
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +22 -2
M internal/protocol/catalogue/catalogue.go +1 -1
@@ -246,7 +246,7 @@ func validText(value string) bool {
246246 return false
247247 }
248248 for _, r := range value {
249- if unicode.IsControl(r) {
249+ if unicode.IsControl(r) || unicode.Is(unicode.Bidi_Control, r) || r == '\u2028' || r == '\u2029' {
250250 return false
251251 }
252252 }
@@ -246,7 +246,7 @@ func validText(value string) bool {
246 return false246 return false
247 }247 }
248 for _, r := range value {248 for _, r := range value {
249- if unicode.IsControl(r) {249+ if unicode.IsControl(r) || unicode.Is(unicode.Bidi_Control, r) || r == '\u2028' || r == '\u2029' {
250 return false250 return false
251 }251 }
252 }252 }
M internal/protocol/catalogue/catalogue_test.go +20 -0
@@ -41,6 +41,26 @@ func TestRemoteCatalogStateContract(t *testing.T) {
4141 require.Equal(t, RemoteCatalogSchemaVersion, mismatch.Want)
4242 }
4343
44+func TestValidateRemoteCatalogRejectsTerminalUnsafeText(t *testing.T) {
45+ for _, tt := range []struct{ name, value string }{
46+ {name: "bidi override", value: "\u202eoverride"},
47+ {name: "line separator", value: "\u2028line separator"},
48+ {name: "paragraph separator", value: "\u2029paragraph separator"},
49+ } {
50+ t.Run(tt.name, func(t *testing.T) {
51+ err := ValidateRemoteCatalog(RemoteCatalog{
52+ ProtocolVersion: protocol.Version,
53+ SchemaVersion: RemoteCatalogSchemaVersion,
54+ Sessions: []RemoteCatalogSession{{
55+ LifecycleID: [16]byte{1}, Name: "work", State: RemoteCatalogSessionUp,
56+ Tabs: []RemoteCatalogTab{{ID: "tab-1", Detail: tt.value}},
57+ }},
58+ })
59+ require.ErrorIs(t, err, ErrInvalidRemoteCatalog)
60+ })
61+ }
62+}
63+
4464 func TestValidateRemoteCatalogRejectsDuplicateSessionNames(t *testing.T) {
4565 err := ValidateRemoteCatalog(RemoteCatalog{
4666 ProtocolVersion: protocol.Version,
@@ -41,6 +41,26 @@ func TestRemoteCatalogStateContract(t *testing.T) {
41 require.Equal(t, RemoteCatalogSchemaVersion, mismatch.Want)41 require.Equal(t, RemoteCatalogSchemaVersion, mismatch.Want)
42 }42 }
43 43
44+func TestValidateRemoteCatalogRejectsTerminalUnsafeText(t *testing.T) {
45+ for _, tt := range []struct{ name, value string }{
46+ {name: "bidi override", value: "\u202eoverride"},
47+ {name: "line separator", value: "\u2028line separator"},
48+ {name: "paragraph separator", value: "\u2029paragraph separator"},
49+ } {
50+ t.Run(tt.name, func(t *testing.T) {
51+ err := ValidateRemoteCatalog(RemoteCatalog{
52+ ProtocolVersion: protocol.Version,
53+ SchemaVersion: RemoteCatalogSchemaVersion,
54+ Sessions: []RemoteCatalogSession{{
55+ LifecycleID: [16]byte{1}, Name: "work", State: RemoteCatalogSessionUp,
56+ Tabs: []RemoteCatalogTab{{ID: "tab-1", Detail: tt.value}},
57+ }},
58+ })
59+ require.ErrorIs(t, err, ErrInvalidRemoteCatalog)
60+ })
61+ }
62+}
63+
44 func TestValidateRemoteCatalogRejectsDuplicateSessionNames(t *testing.T) {64 func TestValidateRemoteCatalogRejectsDuplicateSessionNames(t *testing.T) {
45 err := ValidateRemoteCatalog(RemoteCatalog{65 err := ValidateRemoteCatalog(RemoteCatalog{
46 ProtocolVersion: protocol.Version,66 ProtocolVersion: protocol.Version,
M internal/protocol/wire/wire_final_test.go +1 -1
@@ -158,7 +158,7 @@ func TestFinalOutputSemanticValidationBeforeDataAllocation(t *testing.T) {
158158 {name: "size zero", mutate: func(b []byte) { binary.BigEndian.PutUint16(b[40:42], 0) }},
159159 {name: "full flag false for reset", mutate: func(b []byte) { b[44] = 0 }},
160160 {name: "invalid bool", mutate: func(b []byte) { b[44] = 2 }},
161- {name: "impossible data length", mutate: func(b []byte) { binary.BigEndian.PutUint32(b[50:54], ^uint32(0)) }},
161+ {name: "impossible data length", mutate: func(b []byte) { binary.BigEndian.PutUint32(b[46:50], ^uint32(0)) }},
162162 } {
163163 t.Run(tt.name, func(t *testing.T) {
164164 bad := append([]byte(nil), payload...)
@@ -158,7 +158,7 @@ func TestFinalOutputSemanticValidationBeforeDataAllocation(t *testing.T) {
158 {name: "size zero", mutate: func(b []byte) { binary.BigEndian.PutUint16(b[40:42], 0) }},158 {name: "size zero", mutate: func(b []byte) { binary.BigEndian.PutUint16(b[40:42], 0) }},
159 {name: "full flag false for reset", mutate: func(b []byte) { b[44] = 0 }},159 {name: "full flag false for reset", mutate: func(b []byte) { b[44] = 0 }},
160 {name: "invalid bool", mutate: func(b []byte) { b[44] = 2 }},160 {name: "invalid bool", mutate: func(b []byte) { b[44] = 2 }},
161- {name: "impossible data length", mutate: func(b []byte) { binary.BigEndian.PutUint32(b[50:54], ^uint32(0)) }},161+ {name: "impossible data length", mutate: func(b []byte) { binary.BigEndian.PutUint32(b[46:50], ^uint32(0)) }},
162 } {162 } {
163 t.Run(tt.name, func(t *testing.T) {163 t.Run(tt.name, func(t *testing.T) {
164 bad := append([]byte(nil), payload...)164 bad := append([]byte(nil), payload...)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close