detached c7d4ad1

fix(domain): reject line separators and bidi marks in merge request text

commit
c7d4ad1e4076f28d00dfbdf42521925ac6474477
author
brice · · 2026-10-10 17:37 UTC
parent
59b81ca
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +20 -6
M internal/domain/text.go +9 -5
@@ -59,7 +59,8 @@ func (t MRTitle) IsZero() bool { return t.s == "" }
5959
6060 // PlainText is a validated multi-line plain text, such as a description or a
6161 // comment: valid UTF-8, at most MaxTextLen characters, line breaks allowed,
62-// no other control character, no bidirectional override or isolate character.
62+// no other control character, no U+2028 or U+2029, no bidirectional mark,
63+// override or isolate character.
6364 // The zero value is the empty text.
6465 type PlainText struct{ s string }
6566
@@ -103,6 +104,8 @@ func textProblem(s string, multiline bool, maxLen int) string {
103104 }
104105 case unicode.IsControl(r):
105106 return fmt.Sprintf("contains the control character U+%04X", r)
107+ case r == '\u2028' || r == '\u2029':
108+ return fmt.Sprintf("contains the line or paragraph separator U+%04X", r)
106109 case isBidiControl(r):
107110 return fmt.Sprintf("contains the bidirectional override or isolate character U+%04X", r)
108111 }
@@ -110,11 +113,12 @@ func textProblem(s string, multiline bool, maxLen int) string {
110113 return ""
111114 }
112115
113-// isBidiControl reports whether r is a bidirectional embedding, override or
114-// isolate character (U+202A to U+202E, U+2066 to U+2069), which can make text
115-// read differently from how it is stored.
116+// isBidiControl reports whether r is a bidirectional mark, embedding, override
117+// or isolate character (U+200E, U+200F, U+061C, U+202A to U+202E, U+2066 to
118+// U+2069), which can make text read differently from how it is stored.
116119 func isBidiControl(r rune) bool {
117- return (r >= 0x202a && r <= 0x202e) || (r >= 0x2066 && r <= 0x2069)
120+ return r == 0x200e || r == 0x200f || r == 0x061c ||
121+ (r >= 0x202a && r <= 0x202e) || (r >= 0x2066 && r <= 0x2069)
118122 }
119123
120124 // FieldViolation names a request field that breaks a rule.
@@ -59,7 +59,8 @@ func (t MRTitle) IsZero() bool { return t.s == "" }
59 59
60 // PlainText is a validated multi-line plain text, such as a description or a60 // PlainText is a validated multi-line plain text, such as a description or a
61 // comment: valid UTF-8, at most MaxTextLen characters, line breaks allowed,61 // comment: valid UTF-8, at most MaxTextLen characters, line breaks allowed,
62-// no other control character, no bidirectional override or isolate character.62+// no other control character, no U+2028 or U+2029, no bidirectional mark,
63+// override or isolate character.
63 // The zero value is the empty text.64 // The zero value is the empty text.
64 type PlainText struct{ s string }65 type PlainText struct{ s string }
65 66
@@ -103,6 +104,8 @@ func textProblem(s string, multiline bool, maxLen int) string {
103 }104 }
104 case unicode.IsControl(r):105 case unicode.IsControl(r):
105 return fmt.Sprintf("contains the control character U+%04X", r)106 return fmt.Sprintf("contains the control character U+%04X", r)
107+ case r == '\u2028' || r == '\u2029':
108+ return fmt.Sprintf("contains the line or paragraph separator U+%04X", r)
106 case isBidiControl(r):109 case isBidiControl(r):
107 return fmt.Sprintf("contains the bidirectional override or isolate character U+%04X", r)110 return fmt.Sprintf("contains the bidirectional override or isolate character U+%04X", r)
108 }111 }
@@ -110,11 +113,12 @@ func textProblem(s string, multiline bool, maxLen int) string {
110 return ""113 return ""
111 }114 }
112 115
113-// isBidiControl reports whether r is a bidirectional embedding, override or116+// isBidiControl reports whether r is a bidirectional mark, embedding, override
114-// isolate character (U+202A to U+202E, U+2066 to U+2069), which can make text117+// or isolate character (U+200E, U+200F, U+061C, U+202A to U+202E, U+2066 to
115-// read differently from how it is stored.118+// U+2069), which can make text read differently from how it is stored.
116 func isBidiControl(r rune) bool {119 func isBidiControl(r rune) bool {
117- return (r >= 0x202a && r <= 0x202e) || (r >= 0x2066 && r <= 0x2069)120+ return r == 0x200e || r == 0x200f || r == 0x061c ||
121+ (r >= 0x202a && r <= 0x202e) || (r >= 0x2066 && r <= 0x2069)
118 }122 }
119 123
120 // FieldViolation names a request field that breaks a rule.124 // FieldViolation names a request field that breaks a rule.
M internal/domain/text_test.go +11 -1
@@ -39,6 +39,11 @@ func TestParseMRTitle(t *testing.T) {
3939 {"bidi embedding", "a\u202ab", "U+202A"},
4040 {"bidi isolate", "a\u2066b", "U+2066"},
4141 {"bidi pop isolate", "a\u2069b", "U+2069"},
42+ {"line separator", "a\u2028b", "U+2028"},
43+ {"paragraph separator", "a\u2029b", "U+2029"},
44+ {"left-to-right mark", "a\u200eb", "U+200E"},
45+ {"right-to-left mark", "a\u200fb", "U+200F"},
46+ {"arabic letter mark", "a\u061cb", "U+061C"},
4247 {"zero width joiner is plain text", "a\u200db", ""},
4348 }
4449 for _, tc := range tests {
@@ -78,7 +83,12 @@ func TestParsePlainText(t *testing.T) {
7883 {"bidi override", "x\u202ey", "U+202E"},
7984 {"bidi left-to-right isolate", "x\u2066y", "U+2066"},
8085 {"bidi first strong isolate", "x\u2068y", "U+2068"},
81- {"left-to-right mark is plain text", "x\u200ey", ""},
86+ {"line separator", "x\u2028y", "U+2028"},
87+ {"paragraph separator", "x\u2029y", "U+2029"},
88+ {"left-to-right mark", "x\u200ey", "U+200E"},
89+ {"right-to-left mark", "x\u200fy", "U+200F"},
90+ {"arabic letter mark", "x\u061cy", "U+061C"},
91+ {"zero width joiner is plain text", "x\u200dy", ""},
8292 }
8393 for _, tc := range tests {
8494 t.Run(tc.name, func(t *testing.T) {
@@ -39,6 +39,11 @@ func TestParseMRTitle(t *testing.T) {
39 {"bidi embedding", "a\u202ab", "U+202A"},39 {"bidi embedding", "a\u202ab", "U+202A"},
40 {"bidi isolate", "a\u2066b", "U+2066"},40 {"bidi isolate", "a\u2066b", "U+2066"},
41 {"bidi pop isolate", "a\u2069b", "U+2069"},41 {"bidi pop isolate", "a\u2069b", "U+2069"},
42+ {"line separator", "a\u2028b", "U+2028"},
43+ {"paragraph separator", "a\u2029b", "U+2029"},
44+ {"left-to-right mark", "a\u200eb", "U+200E"},
45+ {"right-to-left mark", "a\u200fb", "U+200F"},
46+ {"arabic letter mark", "a\u061cb", "U+061C"},
42 {"zero width joiner is plain text", "a\u200db", ""},47 {"zero width joiner is plain text", "a\u200db", ""},
43 }48 }
44 for _, tc := range tests {49 for _, tc := range tests {
@@ -78,7 +83,12 @@ func TestParsePlainText(t *testing.T) {
78 {"bidi override", "x\u202ey", "U+202E"},83 {"bidi override", "x\u202ey", "U+202E"},
79 {"bidi left-to-right isolate", "x\u2066y", "U+2066"},84 {"bidi left-to-right isolate", "x\u2066y", "U+2066"},
80 {"bidi first strong isolate", "x\u2068y", "U+2068"},85 {"bidi first strong isolate", "x\u2068y", "U+2068"},
81- {"left-to-right mark is plain text", "x\u200ey", ""},86+ {"line separator", "x\u2028y", "U+2028"},
87+ {"paragraph separator", "x\u2029y", "U+2029"},
88+ {"left-to-right mark", "x\u200ey", "U+200E"},
89+ {"right-to-left mark", "x\u200fy", "U+200F"},
90+ {"arabic letter mark", "x\u061cy", "U+061C"},
91+ {"zero width joiner is plain text", "x\u200dy", ""},
82 }92 }
83 for _, tc := range tests {93 for _, tc := range tests {
84 t.Run(tc.name, func(t *testing.T) {94 t.Run(tc.name, func(t *testing.T) {

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 2

Tags 41

↑↓ moveenter openesc close