fix(sanitize): preserve code blocks and inline code from HTML entity escaping

Bluemonday operates on raw text, so characters like && and <> inside
markdown code blocks/inline code were being HTML-escaped (e.g. && → &amp;&amp;),
causing them to render incorrectly in the frontend.

Now extracts fenced code blocks and inline code spans before sanitization,
runs bluemonday on the remaining content, then restores the code verbatim.
This commit is contained in:
Jiayuan Zhang
2026-04-12 17:06:45 +08:00
parent 9ed80120e0
commit 72f99b88fb
2 changed files with 88 additions and 6 deletions

View File

@@ -1,7 +1,9 @@
package sanitize
import (
"fmt"
"regexp"
"strings"
"github.com/microcosm-cc/bluemonday"
)
@@ -11,11 +13,6 @@ var httpURL = regexp.MustCompile(`^https?://`)
// policy is a shared bluemonday policy that allows safe Markdown HTML while
// stripping dangerous elements (script, iframe, object, embed, style, on*).
//
// Note: bluemonday operates on raw text, so HTML inside Markdown code blocks
// (e.g. ```<script>```) will also be stripped. This is an acceptable trade-off
// for defense-in-depth — the primary sanitization happens in the frontend via
// rehype-sanitize which understands the Markdown AST.
var policy *bluemonday.Policy
func init() {
@@ -28,8 +25,47 @@ func init() {
policy.AllowAttrs("class").OnElements("code", "div", "span", "pre")
}
// fencedCodeBlock matches ``` or ~~~ fenced code blocks (with optional language tag).
var fencedCodeBlock = regexp.MustCompile("(?m)^(```|~~~)[^\n]*\n[\\s\\S]*?\n(```|~~~)[ \t]*$")
// inlineCode matches backtick-delimited inline code spans.
// Ordered longest-delimiter-first so triple backticks match before doubles/singles.
var inlineCode = regexp.MustCompile("```[^`]+```|``[^`]+``|`[^`]+`")
// HTML sanitizes user-provided HTML/Markdown content, stripping dangerous
// tags (script, iframe, object, embed, etc.) and event-handler attributes.
//
// Code blocks and inline code spans are preserved verbatim so that bluemonday
// does not HTML-escape their contents (e.g. && → &amp;&amp;).
func HTML(input string) string {
return policy.Sanitize(input)
// 1. Extract fenced code blocks, replacing with unique placeholders.
var blocks []string
placeholder := func(i int) string { return fmt.Sprintf("\x00CODEBLOCK_%d\x00", i) }
result := fencedCodeBlock.ReplaceAllStringFunc(input, func(m string) string {
idx := len(blocks)
blocks = append(blocks, m)
return placeholder(idx)
})
// 2. Extract inline code spans.
var inlines []string
inlinePH := func(i int) string { return fmt.Sprintf("\x00INLINE_%d\x00", i) }
result = inlineCode.ReplaceAllStringFunc(result, func(m string) string {
idx := len(inlines)
inlines = append(inlines, m)
return inlinePH(idx)
})
// 3. Sanitize the non-code portions.
result = policy.Sanitize(result)
// 4. Restore inline code spans, then fenced code blocks.
for i, code := range inlines {
result = strings.Replace(result, inlinePH(i), code, 1)
}
for i, block := range blocks {
result = strings.Replace(result, placeholder(i), block, 1)
}
return result
}

View File

@@ -80,6 +80,52 @@ func TestHTML(t *testing.T) {
input: `<div data-type="fileCard" data-href="http://example.com/file.pdf" data-filename="file.pdf"></div>`,
want: `<div data-type="fileCard" data-href="http://example.com/file.pdf" data-filename="file.pdf"></div>`,
},
// Code block preservation — entities must NOT be escaped inside code.
{
name: "fenced code block preserves ampersands",
input: "```\na && b\n```",
want: "```\na && b\n```",
},
{
name: "fenced code block preserves angle brackets",
input: "```html\n<div class=\"x\">hello</div>\n```",
want: "```html\n<div class=\"x\">hello</div>\n```",
},
{
name: "inline code preserves ampersands",
input: "run `a && b` in shell",
want: "run `a && b` in shell",
},
{
name: "inline code preserves angle brackets",
input: "use `x < y && y > z`",
want: "use `x < y && y > z`",
},
{
name: "double backtick inline code preserved",
input: "use ``a && b`` here",
want: "use ``a && b`` here",
},
{
name: "script in fenced code block preserved",
input: "```\n<script>alert(1)</script>\n```",
want: "```\n<script>alert(1)</script>\n```",
},
{
name: "script outside code block still stripped",
input: "hello <script>alert(1)</script> world",
want: "hello world",
},
{
name: "mixed code and non-code",
input: "text `a && b` more <script>x</script> end",
want: "text `a && b` more end",
},
{
name: "tilde fenced code block preserves content",
input: "~~~\na && b\n~~~",
want: "~~~\na && b\n~~~",
},
}
for _, tt := range tests {