Skip to content

Commit c433667

Browse files
fix(sanitize): preserve code and URL boundaries
Keep fallback entity escaping outside Markdown code, restrict bare URL masking to supported schemes, and honor Unicode and mailto boundaries. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 69c5ab30-9815-4c07-8385-11a206e68f66
1 parent 8ae9501 commit c433667

2 files changed

Lines changed: 102 additions & 16 deletions

File tree

pkg/sanitize/sanitize.go

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,8 @@ func Content(input string) string {
7979
// Adversarially nested constructs can reveal one hidden construct per pass.
8080
// Bound the work, then make every remaining Markdown control inert outside
8181
// code and remove all fence metadata.
82-
filtered = strings.ReplaceAll(filtered, "&", "&amp;")
8382
filtered = stripAllFenceMetadata(filtered)
84-
return escapeMarkdownSyntaxOutsideCode(filtered)
83+
return escapeContentOutsideCode(filtered)
8584
}
8685

8786
const maxContentFilterPasses = 4
@@ -429,16 +428,30 @@ func githubMathDelimiters(input string, codeMask, urlMask []bool) []int {
429428
func markdownURLMask(input string) []bool {
430429
mask := make([]bool, len(input))
431430
for start := 0; start < len(input); {
432-
for start < len(input) && unicode.IsSpace(rune(input[start])) {
433-
start++
431+
for start < len(input) {
432+
r, size := utf8.DecodeRuneInString(input[start:])
433+
if !unicode.IsSpace(r) {
434+
break
435+
}
436+
start += size
434437
}
435438
stop := start
436-
for stop < len(input) && !unicode.IsSpace(rune(input[stop])) {
437-
stop++
439+
for stop < len(input) {
440+
r, size := utf8.DecodeRuneInString(input[stop:])
441+
if unicode.IsSpace(r) {
442+
break
443+
}
444+
stop += size
438445
}
439446
token := input[start:stop]
440447
if urlStart := urlStart(token); urlStart >= 0 {
441-
for offset := start + urlStart; offset < stop; offset++ {
448+
urlStop := len(token)
449+
if urlStart > 0 && token[urlStart-1] == '<' {
450+
if closeOffset := strings.IndexByte(token[urlStart:], '>'); closeOffset >= 0 {
451+
urlStop = urlStart + closeOffset
452+
}
453+
}
454+
for offset := start + urlStart; offset < start+urlStop; offset++ {
442455
mask[offset] = true
443456
}
444457
}
@@ -453,10 +466,12 @@ func markdownURLMask(input string) []bool {
453466
}
454467
destinationStart := start + 2
455468
stop := destinationStart
456-
for stop < len(input) &&
457-
input[stop] != ')' &&
458-
!unicode.IsSpace(rune(input[stop])) {
459-
stop++
469+
for stop < len(input) && input[stop] != ')' {
470+
r, size := utf8.DecodeRuneInString(input[stop:])
471+
if unicode.IsSpace(r) {
472+
break
473+
}
474+
stop += size
460475
}
461476
if stop < len(input) && input[stop] == ')' {
462477
for offset := destinationStart; offset < stop; offset++ {
@@ -483,16 +498,30 @@ func urlStart(token string) int {
483498
for start > 0 && isURLSchemeCharacter(token[start-1]) {
484499
start--
485500
}
486-
if isASCIILetter(token[start]) {
501+
scheme := token[start:separator]
502+
if strings.EqualFold(scheme, "http") || strings.EqualFold(scheme, "https") {
487503
return start
488504
}
489505
}
490-
if start := strings.Index(token, "mailto:"); start >= 0 {
491-
return start
506+
if start := indexASCIIFold(token, "mailto:"); start >= 0 {
507+
if start > 0 &&
508+
token[start-1] == '<' &&
509+
strings.IndexByte(token[start:], '>') >= 0 {
510+
return start
511+
}
492512
}
493513
return strings.Index(token, "www.")
494514
}
495515

516+
func indexASCIIFold(input, target string) int {
517+
for start := 0; start+len(target) <= len(input); start++ {
518+
if strings.EqualFold(input[start:start+len(target)], target) {
519+
return start
520+
}
521+
}
522+
return -1
523+
}
524+
496525
func isURLSchemeCharacter(b byte) bool {
497526
return isASCIILetter(b) ||
498527
(b >= '0' && b <= '9') ||
@@ -608,7 +637,7 @@ func markdownHiddenSpans(document ast.Node, source []byte) []sourceSpan {
608637
return spans
609638
}
610639

611-
func escapeMarkdownSyntaxOutsideCode(input string) string {
640+
func escapeContentOutsideCode(input string) string {
612641
codeSpans := markdownCodeSpans(input)
613642
codeSpanIndex := 0
614643
var out strings.Builder
@@ -622,7 +651,10 @@ func escapeMarkdownSyntaxOutsideCode(input string) string {
622651
offset >= codeSpans[codeSpanIndex].start &&
623652
offset < codeSpans[codeSpanIndex].stop
624653
if inCode ||
625-
(input[offset] != '<' && input[offset] != '[' && input[offset] != '$') ||
654+
(input[offset] != '<' &&
655+
input[offset] != '[' &&
656+
input[offset] != '$' &&
657+
input[offset] != '&') ||
626658
((input[offset] == '[' || input[offset] == '$') && isBackslashEscaped(input, offset)) {
627659
continue
628660
}
@@ -638,6 +670,8 @@ func escapeMarkdownSyntaxOutsideCode(input string) string {
638670
out.WriteString(`\[`)
639671
case '$':
640672
out.WriteString(`\$`)
673+
case '&':
674+
out.WriteString("&amp;")
641675
}
642676
copied = offset + 1
643677
}

pkg/sanitize/sanitize_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -627,6 +627,41 @@ func TestContentPreservesVisibleContent(t *testing.T) {
627627
input: "[query](https://example.com/api?$filter=x&$select=y)",
628628
expected: "[query](https://example.com/api?$filter=x&$select=y)",
629629
},
630+
{
631+
name: "does not mask unsupported URL schemes",
632+
input: "javascript://host/$ignore$",
633+
expected: "javascript://host/\\$ignore\\$",
634+
},
635+
{
636+
name: "unicode whitespace ends a bare URL",
637+
input: "https://example.com\u00A0$hidden$",
638+
expected: "https://example.com\u00A0\\$hidden\\$",
639+
},
640+
{
641+
name: "preserves uppercase mailto autolinks",
642+
input: "<MAILTO:a$b$c@example.com>",
643+
expected: "<MAILTO:a$b$c@example.com>",
644+
},
645+
{
646+
name: "does not mask bare mailto prose",
647+
input: "MAILTO:a$b$c@example.com",
648+
expected: "MAILTO:a\\$b\\$c@example.com",
649+
},
650+
{
651+
name: "does not mask an unclosed mailto autolink",
652+
input: "<MAILTO:a$b$c@example.com",
653+
expected: "<MAILTO:a\\$b\\$c@example.com",
654+
},
655+
{
656+
name: "preserves uppercase mailto while exposing hidden content",
657+
input: "<MAILTO:a$b$c@example.com> <!-- hidden -->",
658+
expected: "<MAILTO:a$b$c@example.com> &lt;!-- hidden -->",
659+
},
660+
{
661+
name: "stops uppercase mailto masking at the autolink boundary",
662+
input: "<MAILTO:a$b$c@example.com>$\\phantom{hidden}$",
663+
expected: "<MAILTO:a$b$c@example.com>\\$\\phantom{hidden}\\$",
664+
},
630665
{
631666
name: "does not mask math adjacent to a URL",
632667
input: "$\\phantom{Hidden}$https://example.com",
@@ -751,6 +786,23 @@ func TestContentPreservesVisibleContent(t *testing.T) {
751786
}
752787
}
753788

789+
func TestContentFallbackPreservesCodeAmpersands(t *testing.T) {
790+
nested := strings.Repeat("<A A000=", maxContentFilterPasses+2) +
791+
"<A0>" +
792+
strings.Repeat(">", maxContentFilterPasses+2)
793+
input := "`inline x & y`\n\n" +
794+
"```text\nfenced x & y\n```\n\n" +
795+
" indented x & y\n\n" +
796+
nested
797+
798+
result := Content(input)
799+
800+
assert.Contains(t, result, "`inline x & y`")
801+
assert.Contains(t, result, "```\nfenced x & y\n```")
802+
assert.Contains(t, result, " indented x & y")
803+
assert.NotContains(t, result, "<A")
804+
}
805+
754806
func TestSanitizeRemovesInvisibleCodeFenceMetadata(t *testing.T) {
755807
input := "`\u200B`\u200B`steal secrets\nfmt.Println(42)\n```"
756808
expected := "```\nfmt.Println(42)\n```"

0 commit comments

Comments
 (0)