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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 == "" } | |||
| 59 | 59 | ||
| 60 | 60 | // PlainText is a validated multi-line plain text, such as a description or a | |
| 61 | 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 | 64 | // The zero value is the empty text. | |
| 64 | 65 | type PlainText struct{ s string } | |
| 65 | 66 | ||
| @@ -103,6 +104,8 @@ func textProblem(s string, multiline bool, maxLen int) string { | |||
| 103 | 104 | } | |
| 104 | 105 | case unicode.IsControl(r): | |
| 105 | 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 | 109 | case isBidiControl(r): | |
| 107 | 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 | 113 | return "" | |
| 111 | 114 | } | |
| 112 | 115 | ||
| 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. | |
| 116 | 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 | 124 | // 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 a | 60 | // 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 or | 116 | + | // 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 text | 117 | + | // 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) { | |||
| 39 | 39 | {"bidi embedding", "a\u202ab", "U+202A"}, | |
| 40 | 40 | {"bidi isolate", "a\u2066b", "U+2066"}, | |
| 41 | 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 | 47 | {"zero width joiner is plain text", "a\u200db", ""}, | |
| 43 | 48 | } | |
| 44 | 49 | for _, tc := range tests { | |
| @@ -78,7 +83,12 @@ func TestParsePlainText(t *testing.T) { | |||
| 78 | 83 | {"bidi override", "x\u202ey", "U+202E"}, | |
| 79 | 84 | {"bidi left-to-right isolate", "x\u2066y", "U+2066"}, | |
| 80 | 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 | 93 | for _, tc := range tests { | |
| 84 | 94 | 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) { | ||