Files
Sam Morrow 02cf0f7d75 perf(sanitize): make clean text allocation-free on the hot path
Sanitizing user-authored response fields ran multiple allocating passes
over every string regardless of content: FilterInvisibleCharacters
converted the whole input to []rune and back, FilterCodeFenceMetadata
split and rejoined every line, and bluemonday ran unconditionally. On
comment- and issue-heavy responses this dominated conversion CPU and
allocation.

Three changes, none of which alter output or widen what the policy
allows:

- FilterInvisibleCharacters scans first and copies only from the first
  filtered rune, skipping ASCII runs without decoding them. Invalid
  UTF-8 is still re-encoded to U+FFFD, matching the []rune round trip it
  replaces.
- FilterCodeFenceMetadata walks lines in place and returns the input
  when no line changes.
- FilterHTMLTags skips bluemonday for input that is provably a fixed
  point of the policy: printable ASCII, TAB and LF, with none of the
  five characters html.EscapeString rewrites. Sanitize also skips the
  second invisible/code-fence pass when HTML normalization returned its
  input unchanged, since both filters are fixed points there.

Equivalence is pinned by a verbatim copy of the previous pipeline: the
new code is diffed against it over a corpus of ~22k deterministic cases
plus two fuzz targets, and the fast path is checked byte by byte against
the live bluemonday policy.

Benchmarks (Intel Ultra 9 185H, n=6):

  Sanitize/TitleASCII      5.35µs ->  114ns   1 -100% allocs
  Sanitize/Comment1KiB     45.3µs -> 1.27µs   1 -100% allocs
  Sanitize/Body64KiB       2.47ms -> 85.9µs   1 -100% allocs
  30 issues x 2KiB body    3.00ms -> 88.6µs   1.55MiB -> 1.9KiB
  100 comments x 1KiB      5.04ms ->  169µs   2.19MiB -> 6.3KiB

Content that genuinely needs rewriting still pays for it, and non-ASCII
text still goes through bluemonday by design.

Fixes #3117

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-08-19 16:33:23 +02:00

410 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package sanitize
import (
"strings"
"sync"
"unicode"
"unicode/utf8"
"github.com/microcosm-cc/bluemonday"
)
var policy *bluemonday.Policy
var policyOnce sync.Once
func Sanitize(input string) string {
// 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.
filtered := FilterCodeFenceMetadata(FilterInvisibleCharacters(input))
normalized := FilterHTMLTags(filtered)
// HTML processing is the only stage that can introduce a character its input
// did not contain, so when it returns that input byte for byte there is
// nothing new for the second pass to find. Both filters are fixed points on
// the first pass's output, so the second pass is provably the identity here;
// see TestSecondSanitizePassIsRedundantWhenHTMLIsUnchanged.
if normalized == filtered {
return normalized
}
return FilterCodeFenceMetadata(FilterInvisibleCharacters(normalized))
}
// FilterInvisibleCharacters removes invisible or control characters that should not appear
// in user-facing titles or bodies. This includes:
// - Unicode tag characters: U+E0001, U+E0020U+E007F
// - 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
// - 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.
//
// The scan is copy-on-first-match: clean input is returned unchanged with no
// allocation.
func FilterInvisibleCharacters(input string) string {
// Every filtered rune is non-ASCII, so a run of ASCII bytes can be skipped
// without decoding it and an all-ASCII string needs no further work.
for i := range len(input) {
if input[i] >= utf8.RuneSelf {
return filterInvisibleFrom(input, i)
}
}
return input
}
// filterInvisibleFrom resumes FilterInvisibleCharacters at start, the first byte
// that could need filtering. It buffers output only once a rune actually
// changes, so input that turns out to be clean is still returned as-is.
func filterInvisibleFrom(input string, start int) string {
var (
out strings.Builder
prev rune
prevKept bool
copied int
changed bool
)
if start > 0 {
// Everything before start is ASCII, which is never filtered, so the
// preceding byte is both the previous rune and known to have been kept.
prev, prevKept = rune(input[start-1]), true
}
for i := start; i < len(input); {
r, size := utf8.DecodeRuneInString(input[i:])
keep := true
if isVariationSelector(r) {
keep = prevKept && isValidVariationSequence(prev, r)
} else if shouldRemoveRune(r) {
keep = false
}
prev, prevKept = r, keep
// An invalid UTF-8 byte decodes to U+FFFD. The rune-wise filter this
// replaced re-encoded every rune it kept, turning such bytes into
// U+FFFD, so reproduce that instead of passing the raw byte through.
invalid := r == utf8.RuneError && size == 1
if keep && !invalid {
i += size
continue
}
if !changed {
changed = true
out.Grow(len(input))
}
out.WriteString(input[copied:i])
if keep {
out.WriteRune(utf8.RuneError)
}
i += size
copied = i
}
if !changed {
return input
}
out.WriteString(input[copied:])
return out.String()
}
// FilterHTMLTags applies the HTML allowlist policy to input.
func FilterHTMLTags(input string) string {
if input == "" || isHTMLInert(input) {
return input
}
return getPolicy().Sanitize(input)
}
// isHTMLInert reports whether input is provably a fixed point of the HTML
// policy, letting the caller skip it. It is a sufficient condition, deliberately
// narrow, not a description of every fixed point.
//
// The policy tokenizes input as HTML and re-emits text through
// html.EscapeString, so anything it can rewrite must contain at least one of:
// - one of the five characters EscapeString rewrites (ampersand, apostrophe,
// quote, less-than, greater-than), which are also the only way to open a
// tag, comment, doctype or entity;
// - a byte the tokenizer itself rewrites: NUL becomes U+FFFD, CR folds into LF;
// - a byte outside ASCII, which may be part of a malformed UTF-8 sequence.
//
// Printable ASCII minus those five characters, plus TAB and LF, excludes all of
// them. Every accepted byte is checked against the live policy in
// TestHTMLInertBytesAreFixedPointsOfThePolicy.
func isHTMLInert(input string) bool {
for i := range len(input) {
if !htmlInertBytes[input[i]] {
return false
}
}
return true
}
var htmlInertBytes = func() (table [256]bool) {
for c := 0x20; c <= 0x7E; c++ {
table[c] = true
}
table['\t'] = true
table['\n'] = true
for _, c := range []byte{'&', '\'', '"', '<', '>'} {
table[c] = false
}
return table
}()
// FilterCodeFenceMetadata removes hidden or suspicious info strings from fenced code blocks.
//
// Like FilterInvisibleCharacters this is copy-on-first-match: input whose lines
// all survive unchanged is returned without allocating.
func FilterCodeFenceMetadata(input string) string {
if input == "" {
return input
}
var (
out strings.Builder
changed bool
copied int
insideFence bool
currentFenceLen int
)
// Walks the same lines strings.Split(input, "\n") would yield, without
// materialising them.
for start := 0; start <= len(input); {
line := input[start:]
if nl := strings.IndexByte(line, '\n'); nl >= 0 {
line = line[:nl]
}
sanitized, toggled, fenceLen := sanitizeCodeFenceLine(line, insideFence, currentFenceLen)
if toggled {
insideFence = !insideFence
if insideFence {
currentFenceLen = fenceLen
} else {
currentFenceLen = 0
}
}
if sanitized != line {
if !changed {
changed = true
out.Grow(len(input))
}
out.WriteString(input[copied:start])
out.WriteString(sanitized)
copied = start + len(line)
}
start += len(line) + 1
}
if !changed {
return input
}
out.WriteString(input[copied:])
return out.String()
}
const maxCodeFenceInfoLength = 48
func sanitizeCodeFenceLine(line string, insideFence bool, expectedFenceLen int) (string, bool, int) {
idx := strings.Index(line, "```")
if idx == -1 {
return line, false, expectedFenceLen
}
if hasNonWhitespace(line[:idx]) {
return line, false, expectedFenceLen
}
fenceEnd := idx
for fenceEnd < len(line) && line[fenceEnd] == '`' {
fenceEnd++
}
fenceLen := fenceEnd - idx
if fenceLen < 3 {
return line, false, expectedFenceLen
}
rest := line[fenceEnd:]
if insideFence {
if expectedFenceLen != 0 && fenceLen != expectedFenceLen {
return line, false, expectedFenceLen
}
return line[:fenceEnd], true, fenceLen
}
trimmed := strings.TrimSpace(rest)
if trimmed == "" {
return line[:fenceEnd], true, fenceLen
}
if strings.IndexFunc(trimmed, unicode.IsSpace) != -1 {
return line[:fenceEnd], true, fenceLen
}
if len(trimmed) > maxCodeFenceInfoLength {
return line[:fenceEnd], true, fenceLen
}
if !isSafeCodeFenceToken(trimmed) {
return line[:fenceEnd], true, fenceLen
}
// Reconstructing the line would allocate a copy of what is already there,
// so return the original when normalization is a no-op.
if rest == trimmed {
return line, true, fenceLen
}
if len(rest) > 0 && unicode.IsSpace(rune(rest[0])) {
if rest[0] == ' ' && len(rest) == len(trimmed)+1 {
return line, true, fenceLen
}
return line[:fenceEnd] + " " + trimmed, true, fenceLen
}
return line[:fenceEnd] + trimmed, true, fenceLen
}
func hasNonWhitespace(segment string) bool {
for _, r := range segment {
if !unicode.IsSpace(r) {
return true
}
}
return false
}
func isSafeCodeFenceToken(token string) bool {
for _, r := range token {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
continue
}
switch r {
case '+', '-', '_', '#', '.':
continue
}
return false
}
return true
}
func getPolicy() *bluemonday.Policy {
policyOnce.Do(func() {
p := bluemonday.StrictPolicy()
p.AllowElements(
"b", "blockquote", "br", "code", "em",
"h1", "h2", "h3", "h4", "h5", "h6",
"hr", "i", "li", "ol", "p", "pre",
"strong", "sub", "sup", "table", "tbody",
"td", "th", "thead", "tr", "ul",
"a", "img",
)
p.AllowAttrs("href").OnElements("a")
p.AllowURLSchemes("http", "https")
p.RequireParseableURLs(true)
p.RequireNoFollowOnLinks(true)
p.RequireNoReferrerOnLinks(true)
p.AddTargetBlankToFullyQualifiedLinks(true)
p.AllowImages()
p.AllowAttrs("src", "alt", "title").OnElements("img")
policy = p
})
return policy
}
func shouldRemoveRune(r rune) bool {
switch r {
case 0x200B, // ZERO WIDTH SPACE
0x200C, // ZERO WIDTH NON-JOINER
0x200E, // LEFT-TO-RIGHT MARK
0x200F, // RIGHT-TO-LEFT MARK
0x061C, // ARABIC LETTER MARK
0x00AD, // SOFT HYPHEN
0xFEFF, // ZERO WIDTH NO-BREAK SPACE
0x180E: // MONGOLIAN VOWEL SEPARATOR
return true
case 0xE0001: // TAG
return true
}
// Ranges
// Unicode tags: U+E0020U+E007F
if r >= 0xE0020 && r <= 0xE007F {
return true
}
// BiDi controls: U+202AU+202E
if r >= 0x202A && r <= 0x202E {
return true
}
// BiDi isolates: U+2066U+2069
if r >= 0x2066 && r <= 0x2069 {
return true
}
// Hidden modifiers: U+2060U+2064
if r >= 0x2060 && r <= 0x2064 {
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
}