fix(sanitize): align markdown destination grammar · github/github-mcp-server@c37624f · GitHub
Skip to content

Commit c37624f

Browse files
fix(sanitize): align markdown destination grammar
Restrict angle masking to supported CommonMark autolinks, reject inline destinations crossing blank paragraphs, and parse reference labels with bounded line-local grammar. Preserve valid links while keeping malformed content available for neutralization. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 69c5ab30-9815-4c07-8385-11a206e68f66
1 parent 335eed9 commit c37624f

3 files changed

Lines changed: 184 additions & 18 deletions

File tree

‎pkg/github/sanitize_coverage_test.go‎

Lines changed: 13 additions & 0 deletions

‎pkg/sanitize/sanitize.go‎

Lines changed: 136 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,8 @@ func Content(input string) string {
8585

8686
const maxContentFilterPasses = 4
8787

88-
// maxLinkDestinationLength bounds how far destination-scanning functions look
89-
// for a terminator (matching bracket, closing angle bracket) before giving
90-
// up. Without a bound, an unterminated destination forces a scan to the end
91-
// of input on every call site, which is quadratic across many such
92-
// candidates.
9388
const maxLinkDestinationLength = 4096
89+
const maxLinkLabelLength = 999
9490

9591
var markdownParser = goldmark.DefaultParser()
9692

@@ -511,11 +507,11 @@ func referenceDestinationSpan(source []byte, start int) (sourceSpan, int, bool)
511507
if offset >= len(source) || source[offset] != '[' || isBackslashEscapedBytes(source, offset) {
512508
return sourceSpan{}, start, false
513509
}
514-
closing := bytes.Index(source[offset:], []byte("]:"))
515-
if closing < 0 {
510+
closing, ok := referenceLabelEnd(source, offset)
511+
if !ok {
516512
return sourceSpan{}, start, false
517513
}
518-
destinationStart := offset + closing + 2
514+
destinationStart := closing + 2
519515
for destinationStart < len(source) && (source[destinationStart] == ' ' || source[destinationStart] == '\t') {
520516
destinationStart++
521517
}
@@ -532,6 +528,39 @@ func referenceDestinationSpan(source []byte, start int) (sourceSpan, int, bool)
532528
return span, stop, true
533529
}
534530

531+
func referenceLabelEnd(source []byte, start int) (int, bool) {
532+
depth := 1
533+
characters := 0
534+
for offset := start + 1; offset < len(source) && source[offset] != '\n' && source[offset] != '\r'; {
535+
if characters >= maxLinkLabelLength {
536+
return 0, false
537+
}
538+
if source[offset] == '\\' {
539+
_, size := utf8.DecodeRune(source[offset:])
540+
offset += size
541+
if offset < len(source) && source[offset] != '\n' && source[offset] != '\r' {
542+
_, size = utf8.DecodeRune(source[offset:])
543+
offset += size
544+
characters++
545+
}
546+
continue
547+
}
548+
switch source[offset] {
549+
case '[':
550+
depth++
551+
case ']':
552+
depth--
553+
if depth == 0 {
554+
return offset, offset+1 < len(source) && source[offset+1] == ':'
555+
}
556+
}
557+
_, size := utf8.DecodeRune(source[offset:])
558+
offset += size
559+
characters++
560+
}
561+
return 0, false
562+
}
563+
535564
// referenceLinkDestinationSpan parses a link reference definition's destination,
536565
// which (unlike an inline link destination) is not wrapped in parentheses and
537566
// instead terminates at whitespace, end of line, or an unescaped closing '>'
@@ -588,23 +617,25 @@ func angleAutoLinkSpan(source []byte, start int) (sourceSpan, int, bool) {
588617
if !linkDestinationIsSafe(destination) {
589618
return sourceSpan{}, start, false
590619
}
591-
parsed, err := url.Parse(string(destination))
592-
if err != nil ||
593-
(parsed.Scheme != "" &&
594-
!strings.EqualFold(parsed.Scheme, "http") &&
595-
!strings.EqualFold(parsed.Scheme, "https") &&
596-
!strings.EqualFold(parsed.Scheme, "mailto")) {
620+
if !isCommonMarkAutoLink(destination) {
597621
return sourceSpan{}, start, false
598622
}
599623
return sourceSpan{start: start + 1, stop: stop}, stop + 1, true
600624
}
601625

602626
func inlineDestinationSpan(source []byte, start int) (sourceSpan, int, bool) {
627+
var lineBreaks int
603628
for start < len(source) {
604-
r, size := utf8.DecodeRune(source[start:])
605-
if !unicode.IsSpace(r) {
629+
size, isLineBreak := markdownWhitespaceSize(source[start:])
630+
if size == 0 {
606631
break
607632
}
633+
if isLineBreak {
634+
lineBreaks++
635+
if lineBreaks > 1 {
636+
return sourceSpan{}, start, false
637+
}
638+
}
608639
start += size
609640
}
610641
if start >= len(source) {
@@ -616,8 +647,19 @@ func inlineDestinationSpan(source []byte, start int) (sourceSpan, int, bool) {
616647
for stop := start + 1; stop < limit; stop++ {
617648
if source[stop] == '>' && !isBackslashEscapedBytes(source, stop) {
618649
closing := stop + 1
619-
for closing < len(source) && unicode.IsSpace(rune(source[closing])) {
620-
closing++
650+
lineBreaks = 0
651+
for closing < len(source) {
652+
size, isLineBreak := markdownWhitespaceSize(source[closing:])
653+
if size == 0 {
654+
break
655+
}
656+
if isLineBreak {
657+
lineBreaks++
658+
if lineBreaks > 1 {
659+
return sourceSpan{}, start, false
660+
}
661+
}
662+
closing += size
621663
}
622664
if closing < len(source) && source[closing] == ')' {
623665
return sourceSpan{start: start + 1, stop: stop}, closing + 1, true
@@ -651,6 +693,82 @@ func inlineDestinationSpan(source []byte, start int) (sourceSpan, int, bool) {
651693
return sourceSpan{}, start, false
652694
}
653695

696+
func markdownWhitespaceSize(source []byte) (int, bool) {
697+
switch source[0] {
698+
case ' ', '\t':
699+
return 1, false
700+
case '\n':
701+
return 1, true
702+
case '\r':
703+
if len(source) > 1 && source[1] == '\n' {
704+
return 2, true
705+
}
706+
return 1, true
707+
default:
708+
return 0, false
709+
}
710+
}
711+
712+
func isCommonMarkAutoLink(destination []byte) bool {
713+
schemeEnd := bytes.IndexByte(destination, ':')
714+
if schemeEnd > 0 {
715+
scheme := destination[:schemeEnd]
716+
if bytes.EqualFold(scheme, []byte("mailto")) {
717+
return isMailtoAddress(destination[schemeEnd+1:])
718+
}
719+
if !bytes.EqualFold(scheme, []byte("http")) && !bytes.EqualFold(scheme, []byte("https")) {
720+
return false
721+
}
722+
parsed, err := url.Parse(string(destination))
723+
return err == nil && parsed.Host != ""
724+
}
725+
at := bytes.IndexByte(destination, '@')
726+
return at >= 1 && at+1 < len(destination) &&
727+
isCommonMarkEmail(destination[:at], destination[at+1:])
728+
}
729+
730+
func isMailtoAddress(destination []byte) bool {
731+
at := bytes.IndexByte(destination, '@')
732+
return at > 0 && at+1 < len(destination) && bytes.IndexByte(destination[at+1:], '@') < 0 &&
733+
!bytes.ContainsAny(destination, " \t\r\n<>")
734+
}
735+
736+
func isCommonMarkEmail(local, domain []byte) bool {
737+
if domain == nil {
738+
at := bytes.IndexByte(local, '@')
739+
if at < 1 || at+1 >= len(local) {
740+
return false
741+
}
742+
domain = local[at+1:]
743+
local = local[:at]
744+
}
745+
for _, character := range local {
746+
if !isEmailLocalCharacter(character) {
747+
return false
748+
}
749+
}
750+
if !isASCIIAlphaNumeric(domain[0]) {
751+
return false
752+
}
753+
for _, character := range domain {
754+
switch {
755+
case isASCIIAlphaNumeric(character), character == '.', character == '-':
756+
default:
757+
return false
758+
}
759+
}
760+
return domain[len(domain)-1] != '.' && domain[len(domain)-1] != '-'
761+
}
762+
763+
func isEmailLocalCharacter(character byte) bool {
764+
return isASCIIAlphaNumeric(character) || strings.ContainsRune("._+-!#$%&'*+/=?^`{|}~", rune(character))
765+
}
766+
767+
func isASCIIAlphaNumeric(character byte) bool {
768+
return (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') ||
769+
(character >= '0' && character <= '9')
770+
}
771+
654772
func bareHTTPURLSpan(source []byte, start int) (sourceSpan, bool) {
655773
var prefixLength int
656774
switch {

‎pkg/sanitize/sanitize_test.go‎

Lines changed: 35 additions & 0 deletions

0 commit comments

Comments
 (0)