Skip to content

Commit b1f424f

Browse files
author
realigned
committed
Merge pull request 'WI-327: Fix sanitizer entity-decode stored-XSS regression' (#51) from agent-runs/run-116 into main
Reviewed-on: https://codeberg.org/realigned/windshift-core/pulls/51
2 parents 38fbe0c + d7fc856 commit b1f424f

2 files changed

Lines changed: 145 additions & 9 deletions

File tree

internal/sanitize/sanitize.go

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -207,13 +207,13 @@ var (
207207
)
208208

209209
// stripAndCap is the common path for PlainTextField + ShortIdentifier:
210-
// strip every HTML tag, decode entities so we don't store
211-
// double-encoded text, trim whitespace, length-cap by rune count.
210+
// decode entities, strip every HTML tag, preserve safe decoded prose,
211+
// trim whitespace, length-cap by rune count.
212212
func stripAndCap(input string, maxRunes int) string {
213213
if input == "" {
214214
return input
215215
}
216-
s := html.UnescapeString(strictPolicy.Sanitize(input))
216+
s := sanitizeDecoded(input, strictPolicy)
217217
s = strings.TrimSpace(s)
218218
if maxRunes > 0 && utf8.RuneCountInString(s) > maxRunes {
219219
s = string([]rune(s)[:maxRunes])
@@ -240,17 +240,17 @@ const (
240240
func plainTextField(s string) string { return stripAndCap(s, PlainTextFieldMaxRunes) }
241241
func shortIdentifier(s string) string { return stripAndCap(s, ShortIdentifierMaxRunes) }
242242

243-
// brAllowAndCap is the common path for RichText + LongDocument: strip
244-
// HTML except <br />, decode entities, normalize the bluemonday <br/>
245-
// output back to <br /> for Milkdown compatibility, neutralize
243+
// brAllowAndCap is the common path for RichText + LongDocument:
244+
// decode entities, strip HTML except <br />, normalize bluemonday's
245+
// break output back to <br /> for Milkdown compatibility, neutralize
246246
// dangerous URL schemes, byte-cap.
247247
func brAllowAndCap(input string, maxBytes int) string {
248248
if input == "" || input == "null" {
249249
return ""
250250
}
251-
s := brOnlyPolicy.Sanitize(input)
252-
s = html.UnescapeString(s)
251+
s := sanitizeDecoded(input, brOnlyPolicy)
253252
s = strings.ReplaceAll(s, "<br/>", "<br />")
253+
s = strings.ReplaceAll(s, "<br>", "<br />")
254254
s = markdownURLOnly(s)
255255
if maxBytes > 0 && len(s) > maxBytes {
256256
s = s[:maxBytes]
@@ -265,13 +265,41 @@ func commentPolicy(s string) string {
265265
if s == "" {
266266
return ""
267267
}
268-
out := markdownURLOnly(html.UnescapeString(strictPolicy.Sanitize(s)))
268+
out := sanitizeDecoded(s, strictPolicy)
269+
out = markdownURLOnly(out)
269270
if len(out) > LongTextMaxBytes {
270271
out = out[:LongTextMaxBytes]
271272
}
272273
return out
273274
}
274275

276+
// sanitizeDecoded fully decodes HTML entities before sanitizing so nested
277+
// entity payloads (for example "&amp;lt;img ...&amp;gt;") cannot survive as
278+
// encoded markup that a later decode could reanimate. After sanitizing, it
279+
// decodes only when doing so is stable under the same policy; that preserves
280+
// legit prose like "5 < 6 > 4" without turning escaped tags back into HTML.
281+
func sanitizeDecoded(input string, policy *bluemonday.Policy) string {
282+
s := unescapeRepeated(input)
283+
s = policy.Sanitize(s)
284+
285+
decoded := html.UnescapeString(s)
286+
if policy.Sanitize(decoded) == s {
287+
return decoded
288+
}
289+
return s
290+
}
291+
292+
func unescapeRepeated(s string) string {
293+
for i := 0; i < 8; i++ {
294+
u := html.UnescapeString(s)
295+
if u == s {
296+
return s
297+
}
298+
s = u
299+
}
300+
return s
301+
}
302+
275303
func markdownURLOnly(s string) string {
276304
if s == "" {
277305
return ""

internal/sanitize/sanitize_test.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package sanitize
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
// entityPayload is the canonical finding payload: the < and > around an
9+
// <img onerror> tag are HTML-entity-encoded, so bluemonday tokenizes them
10+
// as character data and StrictPolicy passes them through untouched. The
11+
// post-sanitize html.UnescapeString then reconstitutes a live tag.
12+
const entityPayload = `&lt;img src=x onerror=alert(1)&gt;`
13+
14+
// stripAllTags removes every remaining <...> tag from s; used as an
15+
// assertion helper (the sanitizer's contract is "no HTML in output").
16+
func stripAllTags(t *testing.T, s string) string {
17+
t.Helper()
18+
var b strings.Builder
19+
inTag := false
20+
for _, r := range s {
21+
switch r {
22+
case '<':
23+
inTag = true
24+
case '>':
25+
inTag = false
26+
continue
27+
}
28+
if !inTag {
29+
b.WriteRune(r)
30+
}
31+
}
32+
return b.String()
33+
}
34+
35+
func assertNoRawTag(t *testing.T, policy Policy, label string) {
36+
t.Helper()
37+
out := policy.Sanitize(entityPayload)
38+
if out == entityPayload {
39+
t.Errorf("%s: payload passed through unmodified: %q", label, out)
40+
}
41+
if strings.Contains(strings.ToLower(out), "<img") {
42+
t.Errorf("%s: live <img tag reconstituted after sanitize: %q", label, out)
43+
}
44+
if strings.Contains(strings.ToLower(out), "onerror") {
45+
t.Errorf("%s: onerror handler survived sanitize: %q", label, out)
46+
}
47+
// After stripping any tag residue, no raw tag markup should remain.
48+
if got := stripAllTags(t, out); got != out && strings.Contains(strings.ToLower(out), "<img") {
49+
t.Errorf("%s: output still contained tag markup after tag-strip: %q", label, out)
50+
}
51+
}
52+
53+
func TestPlainTextFieldEntityEncodedXSS(t *testing.T) {
54+
assertNoRawTag(t, PlainTextField, "PlainTextField")
55+
}
56+
57+
func TestShortIdentifierEntityEncodedXSS(t *testing.T) {
58+
assertNoRawTag(t, ShortIdentifier, "ShortIdentifier")
59+
}
60+
61+
func TestRichTextEntityEncodedXSS(t *testing.T) {
62+
assertNoRawTag(t, RichText, "RichText")
63+
}
64+
65+
func TestLongDocumentEntityEncodedXSS(t *testing.T) {
66+
assertNoRawTag(t, LongDocument, "LongDocument")
67+
}
68+
69+
func TestCommentEntityEncodedXSS(t *testing.T) {
70+
assertNoRawTag(t, Comment, "Comment")
71+
}
72+
73+
// Legit entity-encoded prose (e.g. "5 &lt; 6 &gt; 4") must survive as
74+
// plain decoded text, not be eaten. Regression guard for the
75+
// second-sanitize pass turning into over-stripping.
76+
func TestPlainTextFieldPreservesDecodedEntities(t *testing.T) {
77+
got := PlainTextField.Sanitize("5 &lt; 6 &gt; 4")
78+
if want := "5 < 6 > 4"; got != want {
79+
t.Errorf("PlainTextField decoded-prose: got %q want %q", got, want)
80+
}
81+
}
82+
83+
func TestRichTextPreservesDecodedEntities(t *testing.T) {
84+
got := RichText.Sanitize("5 &lt; 6 &gt; 4")
85+
if want := "5 < 6 > 4"; got != want {
86+
t.Errorf("RichText decoded-prose: got %q want %q", got, want)
87+
}
88+
}
89+
90+
// brOnly path must still keep a real <br /> on round-trip (its whole
91+
// reason for existing — Milkdown blank-line preservation).
92+
func TestRichTextPreservesBreakTag(t *testing.T) {
93+
got := RichText.Sanitize("line one<br />line two")
94+
if !strings.Contains(got, "<br />") {
95+
t.Errorf("RichText lost <br />: got %q", got)
96+
}
97+
if strings.Contains(strings.ToLower(got), "<img") {
98+
t.Errorf("RichText leaked img: %q", got)
99+
}
100+
}
101+
102+
// A javascript: Markdown link is neutralized regardless of HTML.
103+
func TestCommentNeutralizesDangerousMarkdownURL(t *testing.T) {
104+
got := Comment.Sanitize("[click](javascript:alert(1))")
105+
if strings.Contains(strings.ToLower(got), "javascript:") {
106+
t.Errorf("Comment kept dangerous scheme: %q", got)
107+
}
108+
}

0 commit comments

Comments
 (0)