Skip to content

Commit 53fc915

Browse files
Re-run fence filter and preserve valid variation sequences
Address review feedback on the post-HTML-entity sanitization pass. Entity decoding could still smuggle code-fence metadata past the sanitizer. A first line such as "`&#8203;``steal secrets" is not a fence in the raw input, so FilterCodeFenceMetadata left it alone; decoding the entity and stripping the zero width space then produced a real fence with its info string intact. Sanitize now re-runs the fence filter after the input is fully normalized. Filtering every variation selector also corrupted legitimate text: VS15 and VS16 select text or emoji presentation, so "✈️" was reduced to "✈", and the Variation Selectors Supplement encodes registered CJK ideographic variation sequences. Selectors are now filtered contextually. A selector is kept when it can apply to the character it follows, and dropped when it is orphaned, follows a removed or non-graphic character, or continues a run of selectors. Supplement selectors additionally require a CJK ideograph base, matching the Ideographic Variation Database. That keeps the anti-smuggling property, since hidden payloads rely on selector runs, without rewriting valid Unicode. Also corrects a lowercase-hex test case that claimed uppercase digits. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent ea2d979 commit 53fc915

2 files changed

Lines changed: 205 additions & 39 deletions

File tree

pkg/sanitize/sanitize.go

Lines changed: 67 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"strings"
55
"sync"
66
"unicode"
7+
"unicode/utf8"
78

89
"github.com/microcosm-cc/bluemonday"
910
)
@@ -12,15 +13,17 @@ var policy *bluemonday.Policy
1213
var policyOnce sync.Once
1314

1415
func Sanitize(input string) string {
15-
// FilterInvisibleCharacters runs both before and after HTML processing.
16-
// The first pass strips raw invisible characters so they don't interfere
17-
// with code-fence parsing. HTML sanitization (FilterHTMLTags) decodes
18-
// character entities (e.g. "&#8203;" or "&#x200b;" become U+200B), which
19-
// can introduce invisible or bidirectional characters that were not
20-
// present as literal runes in the original input. The second pass
21-
// filters the fully normalized output so entity-encoded characters
22-
// cannot survive the policy.
23-
return FilterInvisibleCharacters(FilterHTMLTags(FilterCodeFenceMetadata(FilterInvisibleCharacters(input))))
16+
// The invisible-character and code-fence filters both run before and after
17+
// HTML processing. The first pass strips raw invisible characters so they
18+
// don't interfere with code-fence parsing. HTML sanitization
19+
// (FilterHTMLTags) decodes character entities (e.g. "&#8203;" or
20+
// "&#x200b;" become U+200B), which can introduce invisible or
21+
// bidirectional characters that were not present as literal runes in the
22+
// original input. Those decoded characters can both survive on their own
23+
// and splice previously inert text into a code fence, so the second pass
24+
// re-applies both filters to the fully normalized output.
25+
normalized := FilterHTMLTags(FilterCodeFenceMetadata(FilterInvisibleCharacters(input)))
26+
return FilterCodeFenceMetadata(FilterInvisibleCharacters(normalized))
2427
}
2528

2629
// FilterInvisibleCharacters removes invisible or control characters that should not appear
@@ -29,18 +32,35 @@ func Sanitize(input string) string {
2932
// - BiDi control characters: U+202A–U+202E, U+2066–U+2069
3033
// - BiDi/directional marks: U+200E, U+200F, U+061C
3134
// - Hidden modifier characters: U+200B, U+200C, U+00AD, U+FEFF, U+180E, U+2060–U+2064
32-
// - Variation selectors: U+FE00–U+FE0F, U+E0100–U+E01EF
35+
// - Orphaned variation selectors: U+FE00–U+FE0F, U+E0100–U+E01EF
36+
//
37+
// Variation selectors are filtered contextually rather than unconditionally.
38+
// A selector that forms a plausible variation sequence with the character it
39+
// follows is preserved, so ordinary content such as "✈️", "1️⃣" and CJK
40+
// ideographic variation sequences survive unchanged. Selectors that cannot
41+
// belong to such a sequence — those at the start of the input, those following
42+
// a removed or non-graphic character, and runs of consecutive selectors — are
43+
// removed, which is the shape used to smuggle hidden payloads.
3344
func FilterInvisibleCharacters(input string) string {
3445
if input == "" {
3546
return input
3647
}
3748

3849
// Filter runes
3950
out := make([]rune, 0, len(input))
51+
var prev rune
52+
var prevKept bool
4053
for _, r := range input {
41-
if !shouldRemoveRune(r) {
54+
keep := false
55+
if isVariationSelector(r) {
56+
keep = prevKept && isValidVariationSequence(prev, r)
57+
} else {
58+
keep = !shouldRemoveRune(r)
59+
}
60+
if keep {
4261
out = append(out, r)
4362
}
63+
prev, prevKept = r, keep
4464
}
4565
return string(out)
4666
}
@@ -215,14 +235,43 @@ func shouldRemoveRune(r rune) bool {
215235
if r >= 0x2060 && r <= 0x2064 {
216236
return true
217237
}
218-
// Variation selectors: U+FE00–U+FE0F
219-
if r >= 0xFE00 && r <= 0xFE0F {
220-
return true
238+
239+
return false
240+
}
241+
242+
// isVariationSelector reports whether r is a Unicode variation selector, either
243+
// from the Variation Selectors block (VS1–VS16) or the Variation Selectors
244+
// Supplement (VS17–VS256).
245+
func isVariationSelector(r rune) bool {
246+
return (r >= 0xFE00 && r <= 0xFE0F) || (r >= 0xE0100 && r <= 0xE01EF)
247+
}
248+
249+
// isValidVariationSequence reports whether selector can legitimately apply to
250+
// the base character it immediately follows.
251+
//
252+
// A base may carry at most one selector, so a selector following another
253+
// selector is always rejected; consecutive selectors carry no rendering meaning
254+
// and are the primary way arbitrary data is hidden in text.
255+
func isValidVariationSequence(base, selector rune) bool {
256+
if isVariationSelector(base) || !unicode.IsGraphic(base) || unicode.IsSpace(base) {
257+
return false
221258
}
222-
// Variation selectors supplement: U+E0100–U+E01EF
223-
if r >= 0xE0100 && r <= 0xE01EF {
224-
return true
259+
260+
// The Ideographic Variation Database only registers sequences whose base is
261+
// a CJK ideograph, so supplement selectors are meaningless elsewhere.
262+
if selector >= 0xE0100 {
263+
return unicode.Is(unicode.Han, base)
225264
}
226265

227-
return false
266+
// Standardized variation sequences use non-ASCII bases, except for the
267+
// keycap bases '#', '*' and the ASCII digits, which take a presentation
268+
// selector (VS15/VS16) only.
269+
if base < utf8.RuneSelf {
270+
if base != '#' && base != '*' && (base < '0' || base > '9') {
271+
return false
272+
}
273+
return selector == 0xFE0E || selector == 0xFE0F
274+
}
275+
276+
return true
228277
}

pkg/sanitize/sanitize_test.go

Lines changed: 138 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -118,19 +118,49 @@ func TestFilterInvisibleCharacters(t *testing.T) {
118118
expected: "HelloWorld",
119119
},
120120
{
121-
name: "text with variation selector",
121+
name: "orphaned variation selector after ascii letter",
122122
input: "Hello\uFE0FWorld",
123123
expected: "HelloWorld",
124124
},
125125
{
126-
name: "text with variation selector supplement",
126+
name: "ideographic variation selector after non-ideograph base",
127127
input: "Hello\U000E0100World",
128128
expected: "HelloWorld",
129129
},
130130
{
131-
name: "emoji variation selector hidden after emoji (steganography)",
132-
input: "\U0001F600\uFE0F\U000E0101Hi",
133-
expected: "\U0001F600Hi",
131+
name: "variation selector at start of input has no base",
132+
input: "\uFE0FHello",
133+
expected: "Hello",
134+
},
135+
{
136+
name: "variation selector orphaned by removed zero width space",
137+
input: "\u2708\u200B\uFE0F",
138+
expected: "\u2708",
139+
},
140+
{
141+
name: "smuggled selector run after emoji keeps only the presentation selector",
142+
input: "\U0001F600\uFE0F\U000E0101\U000E0102Hi",
143+
expected: "\U0001F600\uFE0FHi",
144+
},
145+
{
146+
name: "emoji presentation sequence is preserved",
147+
input: "Book a flight \u2708\uFE0F today",
148+
expected: "Book a flight \u2708\uFE0F today",
149+
},
150+
{
151+
name: "text presentation sequence is preserved",
152+
input: "Book a flight \u2708\uFE0E today",
153+
expected: "Book a flight \u2708\uFE0E today",
154+
},
155+
{
156+
name: "keycap sequence is preserved",
157+
input: "Step 1\uFE0F\u20E3 first",
158+
expected: "Step 1\uFE0F\u20E3 first",
159+
},
160+
{
161+
name: "registered cjk ideographic variation sequence is preserved",
162+
input: "\u845B\U000E0100\u57CE",
163+
expected: "\u845B\U000E0100\u57CE",
134164
},
135165
}
136166

@@ -189,19 +219,13 @@ func TestShouldRemoveRune(t *testing.T) {
189219
// Additional directional mark
190220
{name: "arabic letter mark", rune: 0x061C, expected: true},
191221

192-
// Range tests - Variation selectors: U+FE00–U+FE0F
193-
{name: "variation selector range start", rune: 0xFE00, expected: true},
194-
{name: "variation selector range middle", rune: 0xFE05, expected: true},
195-
{name: "variation selector range end (VS16, emoji presentation)", rune: 0xFE0F, expected: true},
196-
{name: "before variation selector range", rune: 0xFDFF, expected: false},
197-
{name: "after variation selector range", rune: 0xFE10, expected: false},
198-
199-
// Range tests - Variation selectors supplement: U+E0100–U+E01EF
200-
{name: "variation selector supplement range start", rune: 0xE0100, expected: true},
201-
{name: "variation selector supplement range middle", rune: 0xE0150, expected: true},
202-
{name: "variation selector supplement range end", rune: 0xE01EF, expected: true},
203-
{name: "before variation selector supplement range", rune: 0xE00FF, expected: false},
204-
{name: "after variation selector supplement range", rune: 0xE01F0, expected: false},
222+
// Variation selectors are filtered contextually by
223+
// FilterInvisibleCharacters, so shouldRemoveRune never removes them on
224+
// its own. See TestIsValidVariationSequence for that behaviour.
225+
{name: "variation selector range start", rune: 0xFE00, expected: false},
226+
{name: "variation selector range end (VS16, emoji presentation)", rune: 0xFE0F, expected: false},
227+
{name: "variation selector supplement range start", rune: 0xE0100, expected: false},
228+
{name: "variation selector supplement range end", rune: 0xE01EF, expected: false},
205229

206230
// Characters that should NOT be removed
207231
{name: "regular ascii letter", rune: 'A', expected: false},
@@ -359,7 +383,7 @@ func TestSanitizeFiltersInvisibleCharactersAfterEntityDecoding(t *testing.T) {
359383
expected: "HelloWorld",
360384
},
361385
{
362-
name: "hexadecimal entity for zero width space (lowercase x, uppercase hex)",
386+
name: "hexadecimal entity for zero width space (lowercase hex digits)",
363387
input: "Hello&#x200b;World",
364388
expected: "HelloWorld",
365389
},
@@ -374,15 +398,20 @@ func TestSanitizeFiltersInvisibleCharactersAfterEntityDecoding(t *testing.T) {
374398
expected: "HelloWorld",
375399
},
376400
{
377-
name: "decimal entity for variation selector",
401+
name: "decimal entity for orphaned variation selector",
378402
input: "Hello&#65039;World",
379403
expected: "HelloWorld",
380404
},
381405
{
382-
name: "hexadecimal entity for variation selector supplement",
406+
name: "hexadecimal entity for orphaned variation selector supplement",
383407
input: "Hello&#xE0100;World",
384408
expected: "HelloWorld",
385409
},
410+
{
411+
name: "entity encoded selector run after emoji is truncated to one selector",
412+
input: "Ship it \U0001F600&#xFE0F;&#xE0101;&#xE0102;",
413+
expected: "Ship it \U0001F600\uFE0F",
414+
},
386415
{
387416
name: "direct invisible rune alongside entity encoded one",
388417
input: "Hello\u200B&#8206;World",
@@ -403,6 +432,57 @@ func TestSanitizeFiltersInvisibleCharactersAfterEntityDecoding(t *testing.T) {
403432
input: "Hello 世界 🌍 αβγ",
404433
expected: "Hello 世界 🌍 αβγ",
405434
},
435+
{
436+
name: "emoji presentation sequence survives the full pipeline",
437+
input: "Book a flight \u2708\uFE0F today",
438+
expected: "Book a flight \u2708\uFE0F today",
439+
},
440+
{
441+
name: "registered cjk ideographic variation sequence survives the full pipeline",
442+
input: "\u845B\U000E0100\u57CE",
443+
expected: "\u845B\U000E0100\u57CE",
444+
},
445+
}
446+
447+
for _, tt := range tests {
448+
t.Run(tt.name, func(t *testing.T) {
449+
result := Sanitize(tt.input)
450+
assert.Equal(t, tt.expected, result)
451+
})
452+
}
453+
}
454+
455+
// TestSanitizeRemovesCodeFenceMetadataRevealedByEntityDecoding covers fences
456+
// that only become fences after HTML entity decoding. A leading "`&#8203;“"
457+
// is not a fence in the raw input, so the first FilterCodeFenceMetadata pass
458+
// leaves it alone; once the entity is decoded and the zero width space is
459+
// removed the line is a real fence, so the fence filter has to run again.
460+
func TestSanitizeRemovesCodeFenceMetadataRevealedByEntityDecoding(t *testing.T) {
461+
tests := []struct {
462+
name string
463+
input string
464+
expected string
465+
}{
466+
{
467+
name: "decimal entity hides fence delimiter",
468+
input: "`&#8203;``steal secrets\nfmt.Println(42)\n```",
469+
expected: "```\nfmt.Println(42)\n```",
470+
},
471+
{
472+
name: "hexadecimal entity hides fence delimiter",
473+
input: "``&#x200b;`steal secrets\nfmt.Println(42)\n```",
474+
expected: "```\nfmt.Println(42)\n```",
475+
},
476+
{
477+
name: "entity hides fence delimiter with disallowed info string",
478+
input: "`&#8203;``go;rm -rf /\ncode\n```",
479+
expected: "```\ncode\n```",
480+
},
481+
{
482+
name: "entity encoded fence keeps a safe info string",
483+
input: "`&#8203;``go\nfmt.Println(42)\n```",
484+
expected: "```go\nfmt.Println(42)\n```",
485+
},
406486
}
407487

408488
for _, tt := range tests {
@@ -412,3 +492,40 @@ func TestSanitizeFiltersInvisibleCharactersAfterEntityDecoding(t *testing.T) {
412492
})
413493
}
414494
}
495+
496+
func TestIsValidVariationSequence(t *testing.T) {
497+
tests := []struct {
498+
name string
499+
base rune
500+
selector rune
501+
expected bool
502+
}{
503+
{name: "emoji presentation selector after symbol", base: 0x2708, selector: 0xFE0F, expected: true},
504+
{name: "text presentation selector after symbol", base: 0x2708, selector: 0xFE0E, expected: true},
505+
{name: "presentation selector after emoji", base: 0x1F600, selector: 0xFE0F, expected: true},
506+
{name: "presentation selector after keycap digit", base: '1', selector: 0xFE0F, expected: true},
507+
{name: "presentation selector after keycap hash", base: '#', selector: 0xFE0F, expected: true},
508+
{name: "presentation selector after keycap asterisk", base: '*', selector: 0xFE0E, expected: true},
509+
{name: "non-presentation selector after keycap digit", base: '1', selector: 0xFE00, expected: false},
510+
{name: "presentation selector after ascii letter", base: 'a', selector: 0xFE0F, expected: false},
511+
{name: "presentation selector after ascii punctuation", base: '.', selector: 0xFE0F, expected: false},
512+
{name: "standardized selector after cjk ideograph", base: '葛', selector: 0xFE00, expected: true},
513+
514+
{name: "ideographic selector after cjk ideograph", base: '葛', selector: 0xE0100, expected: true},
515+
{name: "ideographic selector after cjk compatibility ideograph", base: 0xF900, selector: 0xE0101, expected: true},
516+
{name: "ideographic selector after emoji", base: 0x1F600, selector: 0xE0100, expected: false},
517+
{name: "ideographic selector after ascii letter", base: 'a', selector: 0xE0100, expected: false},
518+
{name: "ideographic selector after greek letter", base: 'α', selector: 0xE0100, expected: false},
519+
520+
{name: "selector after another selector", base: 0xFE0F, selector: 0xFE0F, expected: false},
521+
{name: "ideographic selector after another selector", base: 0xE0100, selector: 0xE0101, expected: false},
522+
{name: "selector after space", base: ' ', selector: 0xFE0F, expected: false},
523+
{name: "selector after newline", base: '\n', selector: 0xFE0F, expected: false},
524+
}
525+
526+
for _, tt := range tests {
527+
t.Run(tt.name, func(t *testing.T) {
528+
assert.Equal(t, tt.expected, isValidVariationSequence(tt.base, tt.selector))
529+
})
530+
}
531+
}

0 commit comments

Comments
 (0)