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 "`​``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>
This commit is contained in:
Sam Morrow
2026-08-19 14:48:01 +02:00
parent ea2d979198
commit 53fc915a10
2 changed files with 206 additions and 40 deletions
+68 -19
View File
@@ -4,6 +4,7 @@ import (
"strings"
"sync"
"unicode"
"unicode/utf8"
"github.com/microcosm-cc/bluemonday"
)
@@ -12,15 +13,17 @@ var policy *bluemonday.Policy
var policyOnce sync.Once
func Sanitize(input string) string {
// FilterInvisibleCharacters runs both before and after HTML processing.
// The first pass strips raw invisible characters so they don't interfere
// with code-fence parsing. HTML sanitization (FilterHTMLTags) decodes
// character entities (e.g. "&#8203;" or "&#x200b;" become U+200B), which
// can introduce invisible or bidirectional characters that were not
// present as literal runes in the original input. The second pass
// filters the fully normalized output so entity-encoded characters
// cannot survive the policy.
return FilterInvisibleCharacters(FilterHTMLTags(FilterCodeFenceMetadata(FilterInvisibleCharacters(input))))
// The invisible-character and code-fence filters both run before and after
// HTML processing. The first pass strips raw invisible characters so they
// don't interfere with code-fence parsing. HTML sanitization
// (FilterHTMLTags) decodes character entities (e.g. "&#8203;" or
// "&#x200b;" become U+200B), which can introduce invisible or
// bidirectional characters that were not present as literal runes in the
// original input. Those decoded characters can both survive on their own
// and splice previously inert text into a code fence, so the second pass
// re-applies both filters to the fully normalized output.
normalized := FilterHTMLTags(FilterCodeFenceMetadata(FilterInvisibleCharacters(input)))
return FilterCodeFenceMetadata(FilterInvisibleCharacters(normalized))
}
// FilterInvisibleCharacters removes invisible or control characters that should not appear
@@ -29,7 +32,15 @@ func Sanitize(input string) string {
// - BiDi control characters: U+202AU+202E, U+2066U+2069
// - BiDi/directional marks: U+200E, U+200F, U+061C
// - Hidden modifier characters: U+200B, U+200C, U+00AD, U+FEFF, U+180E, U+2060U+2064
// - Variation selectors: U+FE00U+FE0F, U+E0100U+E01EF
// - Orphaned variation selectors: U+FE00U+FE0F, U+E0100U+E01EF
//
// Variation selectors are filtered contextually rather than unconditionally.
// A selector that forms a plausible variation sequence with the character it
// follows is preserved, so ordinary content such as "✈️", "1️⃣" and CJK
// ideographic variation sequences survive unchanged. Selectors that cannot
// belong to such a sequence — those at the start of the input, those following
// a removed or non-graphic character, and runs of consecutive selectors — are
// removed, which is the shape used to smuggle hidden payloads.
func FilterInvisibleCharacters(input string) string {
if input == "" {
return input
@@ -37,10 +48,19 @@ func FilterInvisibleCharacters(input string) string {
// Filter runes
out := make([]rune, 0, len(input))
var prev rune
var prevKept bool
for _, r := range input {
if !shouldRemoveRune(r) {
keep := false
if isVariationSelector(r) {
keep = prevKept && isValidVariationSequence(prev, r)
} else {
keep = !shouldRemoveRune(r)
}
if keep {
out = append(out, r)
}
prev, prevKept = r, keep
}
return string(out)
}
@@ -215,14 +235,43 @@ func shouldRemoveRune(r rune) bool {
if r >= 0x2060 && r <= 0x2064 {
return true
}
// Variation selectors: U+FE00U+FE0F
if r >= 0xFE00 && r <= 0xFE0F {
return true
}
// Variation selectors supplement: U+E0100U+E01EF
if r >= 0xE0100 && r <= 0xE01EF {
return true
}
return false
}
// isVariationSelector reports whether r is a Unicode variation selector, either
// from the Variation Selectors block (VS1VS16) or the Variation Selectors
// Supplement (VS17VS256).
func isVariationSelector(r rune) bool {
return (r >= 0xFE00 && r <= 0xFE0F) || (r >= 0xE0100 && r <= 0xE01EF)
}
// isValidVariationSequence reports whether selector can legitimately apply to
// the base character it immediately follows.
//
// A base may carry at most one selector, so a selector following another
// selector is always rejected; consecutive selectors carry no rendering meaning
// and are the primary way arbitrary data is hidden in text.
func isValidVariationSequence(base, selector rune) bool {
if isVariationSelector(base) || !unicode.IsGraphic(base) || unicode.IsSpace(base) {
return false
}
// The Ideographic Variation Database only registers sequences whose base is
// a CJK ideograph, so supplement selectors are meaningless elsewhere.
if selector >= 0xE0100 {
return unicode.Is(unicode.Han, base)
}
// Standardized variation sequences use non-ASCII bases, except for the
// keycap bases '#', '*' and the ASCII digits, which take a presentation
// selector (VS15/VS16) only.
if base < utf8.RuneSelf {
if base != '#' && base != '*' && (base < '0' || base > '9') {
return false
}
return selector == 0xFE0E || selector == 0xFE0F
}
return true
}
+138 -21
View File
@@ -118,19 +118,49 @@ func TestFilterInvisibleCharacters(t *testing.T) {
expected: "HelloWorld",
},
{
name: "text with variation selector",
name: "orphaned variation selector after ascii letter",
input: "Hello\uFE0FWorld",
expected: "HelloWorld",
},
{
name: "text with variation selector supplement",
name: "ideographic variation selector after non-ideograph base",
input: "Hello\U000E0100World",
expected: "HelloWorld",
},
{
name: "emoji variation selector hidden after emoji (steganography)",
input: "\U0001F600\uFE0F\U000E0101Hi",
expected: "\U0001F600Hi",
name: "variation selector at start of input has no base",
input: "\uFE0FHello",
expected: "Hello",
},
{
name: "variation selector orphaned by removed zero width space",
input: "\u2708\u200B\uFE0F",
expected: "\u2708",
},
{
name: "smuggled selector run after emoji keeps only the presentation selector",
input: "\U0001F600\uFE0F\U000E0101\U000E0102Hi",
expected: "\U0001F600\uFE0FHi",
},
{
name: "emoji presentation sequence is preserved",
input: "Book a flight \u2708\uFE0F today",
expected: "Book a flight \u2708\uFE0F today",
},
{
name: "text presentation sequence is preserved",
input: "Book a flight \u2708\uFE0E today",
expected: "Book a flight \u2708\uFE0E today",
},
{
name: "keycap sequence is preserved",
input: "Step 1\uFE0F\u20E3 first",
expected: "Step 1\uFE0F\u20E3 first",
},
{
name: "registered cjk ideographic variation sequence is preserved",
input: "\u845B\U000E0100\u57CE",
expected: "\u845B\U000E0100\u57CE",
},
}
@@ -189,19 +219,13 @@ func TestShouldRemoveRune(t *testing.T) {
// Additional directional mark
{name: "arabic letter mark", rune: 0x061C, expected: true},
// Range tests - Variation selectors: U+FE00U+FE0F
{name: "variation selector range start", rune: 0xFE00, expected: true},
{name: "variation selector range middle", rune: 0xFE05, expected: true},
{name: "variation selector range end (VS16, emoji presentation)", rune: 0xFE0F, expected: true},
{name: "before variation selector range", rune: 0xFDFF, expected: false},
{name: "after variation selector range", rune: 0xFE10, expected: false},
// Range tests - Variation selectors supplement: U+E0100U+E01EF
{name: "variation selector supplement range start", rune: 0xE0100, expected: true},
{name: "variation selector supplement range middle", rune: 0xE0150, expected: true},
{name: "variation selector supplement range end", rune: 0xE01EF, expected: true},
{name: "before variation selector supplement range", rune: 0xE00FF, expected: false},
{name: "after variation selector supplement range", rune: 0xE01F0, expected: false},
// Variation selectors are filtered contextually by
// FilterInvisibleCharacters, so shouldRemoveRune never removes them on
// its own. See TestIsValidVariationSequence for that behaviour.
{name: "variation selector range start", rune: 0xFE00, expected: false},
{name: "variation selector range end (VS16, emoji presentation)", rune: 0xFE0F, expected: false},
{name: "variation selector supplement range start", rune: 0xE0100, expected: false},
{name: "variation selector supplement range end", rune: 0xE01EF, expected: false},
// Characters that should NOT be removed
{name: "regular ascii letter", rune: 'A', expected: false},
@@ -359,7 +383,7 @@ func TestSanitizeFiltersInvisibleCharactersAfterEntityDecoding(t *testing.T) {
expected: "HelloWorld",
},
{
name: "hexadecimal entity for zero width space (lowercase x, uppercase hex)",
name: "hexadecimal entity for zero width space (lowercase hex digits)",
input: "Hello&#x200b;World",
expected: "HelloWorld",
},
@@ -374,15 +398,20 @@ func TestSanitizeFiltersInvisibleCharactersAfterEntityDecoding(t *testing.T) {
expected: "HelloWorld",
},
{
name: "decimal entity for variation selector",
name: "decimal entity for orphaned variation selector",
input: "Hello&#65039;World",
expected: "HelloWorld",
},
{
name: "hexadecimal entity for variation selector supplement",
name: "hexadecimal entity for orphaned variation selector supplement",
input: "Hello&#xE0100;World",
expected: "HelloWorld",
},
{
name: "entity encoded selector run after emoji is truncated to one selector",
input: "Ship it \U0001F600&#xFE0F;&#xE0101;&#xE0102;",
expected: "Ship it \U0001F600\uFE0F",
},
{
name: "direct invisible rune alongside entity encoded one",
input: "Hello\u200B&#8206;World",
@@ -403,6 +432,16 @@ func TestSanitizeFiltersInvisibleCharactersAfterEntityDecoding(t *testing.T) {
input: "Hello 世界 🌍 αβγ",
expected: "Hello 世界 🌍 αβγ",
},
{
name: "emoji presentation sequence survives the full pipeline",
input: "Book a flight \u2708\uFE0F today",
expected: "Book a flight \u2708\uFE0F today",
},
{
name: "registered cjk ideographic variation sequence survives the full pipeline",
input: "\u845B\U000E0100\u57CE",
expected: "\u845B\U000E0100\u57CE",
},
}
for _, tt := range tests {
@@ -412,3 +451,81 @@ func TestSanitizeFiltersInvisibleCharactersAfterEntityDecoding(t *testing.T) {
})
}
}
// TestSanitizeRemovesCodeFenceMetadataRevealedByEntityDecoding covers fences
// that only become fences after HTML entity decoding. A leading "`&#8203;“"
// is not a fence in the raw input, so the first FilterCodeFenceMetadata pass
// leaves it alone; once the entity is decoded and the zero width space is
// removed the line is a real fence, so the fence filter has to run again.
func TestSanitizeRemovesCodeFenceMetadataRevealedByEntityDecoding(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "decimal entity hides fence delimiter",
input: "`&#8203;``steal secrets\nfmt.Println(42)\n```",
expected: "```\nfmt.Println(42)\n```",
},
{
name: "hexadecimal entity hides fence delimiter",
input: "``&#x200b;`steal secrets\nfmt.Println(42)\n```",
expected: "```\nfmt.Println(42)\n```",
},
{
name: "entity hides fence delimiter with disallowed info string",
input: "`&#8203;``go;rm -rf /\ncode\n```",
expected: "```\ncode\n```",
},
{
name: "entity encoded fence keeps a safe info string",
input: "`&#8203;``go\nfmt.Println(42)\n```",
expected: "```go\nfmt.Println(42)\n```",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := Sanitize(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestIsValidVariationSequence(t *testing.T) {
tests := []struct {
name string
base rune
selector rune
expected bool
}{
{name: "emoji presentation selector after symbol", base: 0x2708, selector: 0xFE0F, expected: true},
{name: "text presentation selector after symbol", base: 0x2708, selector: 0xFE0E, expected: true},
{name: "presentation selector after emoji", base: 0x1F600, selector: 0xFE0F, expected: true},
{name: "presentation selector after keycap digit", base: '1', selector: 0xFE0F, expected: true},
{name: "presentation selector after keycap hash", base: '#', selector: 0xFE0F, expected: true},
{name: "presentation selector after keycap asterisk", base: '*', selector: 0xFE0E, expected: true},
{name: "non-presentation selector after keycap digit", base: '1', selector: 0xFE00, expected: false},
{name: "presentation selector after ascii letter", base: 'a', selector: 0xFE0F, expected: false},
{name: "presentation selector after ascii punctuation", base: '.', selector: 0xFE0F, expected: false},
{name: "standardized selector after cjk ideograph", base: '葛', selector: 0xFE00, expected: true},
{name: "ideographic selector after cjk ideograph", base: '葛', selector: 0xE0100, expected: true},
{name: "ideographic selector after cjk compatibility ideograph", base: 0xF900, selector: 0xE0101, expected: true},
{name: "ideographic selector after emoji", base: 0x1F600, selector: 0xE0100, expected: false},
{name: "ideographic selector after ascii letter", base: 'a', selector: 0xE0100, expected: false},
{name: "ideographic selector after greek letter", base: 'α', selector: 0xE0100, expected: false},
{name: "selector after another selector", base: 0xFE0F, selector: 0xFE0F, expected: false},
{name: "ideographic selector after another selector", base: 0xE0100, selector: 0xE0101, expected: false},
{name: "selector after space", base: ' ', selector: 0xFE0F, expected: false},
{name: "selector after newline", base: '\n', selector: 0xFE0F, expected: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, isValidVariationSequence(tt.base, tt.selector))
})
}
}