mirror of
https://github.com/hanzoai/team.git
synced 2026-09-30 01:57:35 +00:00
* Cap markdown block nesting depth blockQuoteStart and listStart recursed once per nested '>' or list marker on a line, guarded only by an indentation check that bounds CommonMark indentation, not nesting count. A single crafted line (e.g. "- " repeated tens of thousands of times) forced recursion and slice-copy work that grew super-linearly with nesting depth, letting an authenticated user burn a disproportionate amount of server CPU per request. Thread a depth counter through blockStart, blockStartOrParagraph, blockQuoteStart, and listStart, and stop opening new nested blocks past maxNestingDepth. This bounds nesting whether it is built by one line or accrued across many, and caps the recursion and copy cost to a small constant regardless of input size. Also apply the existing maxLen byte cap directly inside Parse(), not just Inspect(), so any caller of Parse is protected uniformly. * Add tests for markdown block nesting depth cap Cover the new maxNestingDepth guard: rejecting a well-formed block quote/list item once the depth limit is reached, capping recursion for a single deeply-nested line, and honoring depth already accrued by open ancestor blocks. Also cover Parse()'s byte-length cap and add benchmarks showing the nesting-depth cases now scale roughly linearly with input size instead of super-linearly. * Reject oversized posts before markdown parsing createPost, updatePost, and patchPost ran PostWithProxyRemovedFromImageURLs, which parses the raw message, before the message-length check inside Post.IsValid ever ran. An oversized message paid the full parsing cost even though it was always going to be rejected. Add rejectOversizedMessage, mirroring the rune-count check in Post.IsValid, and call it in all three handlers before any markdown processing touches the message. * Add tests for oversized post/patch rejection Cover the rejectOversizedMessage check added for createPost, updatePost, and patchPost: a message over MaxPostSize is rejected with a 400 in all three handlers before it reaches markdown processing. * Sync markdown parse cap with real max post size Parse and Inspect capped input at a hardcoded ~128KB, assuming a 64KB max post size. The real configured limit comes from the posts.message column and can be raised past that default, so on installations with an enlarged column, markdown parsing (mentions, links, formatting) was silently skipped for legitimately-sized posts. Have the markdown package pull its cap from a function registered via SetMaxPostSizeFunc, defaulting to the old conservative constant only if nothing was ever registered. PlatformService.New registers PlatformService.MaxPostSize itself once the store is ready, so the cap always reflects the real limit regardless of which code path parses markdown first, and the store layer stays unaware that markdown parsing exists. * Add tests for markdown max post size sync Cover SetMaxPostSizeFunc raising the cap and being called on every MaxLen invocation. Also verify, end to end against a real database, that PlatformService.New wires markdown.MaxLen to the real configured max post size. * Fix tests * Address review comments - Simplify setting the maximum number of runes for a post - Simplify comments - Rename variables and functions to reflect whether they represent bytes or runes.
160 lines
3.6 KiB
Go
160 lines
3.6 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
// This package implements a parser for the subset of the CommonMark spec necessary for us to do
|
|
// server-side processing. It is not a full implementation and lacks many features. But it is
|
|
// complete enough to efficiently and accurately allow us to do what we need to like rewrite image
|
|
// URLs for proxying.
|
|
package markdown
|
|
|
|
import (
|
|
"strings"
|
|
)
|
|
|
|
func isEscapable(c rune) bool {
|
|
return c > ' ' && (c < '0' || (c > '9' && (c < 'A' || (c > 'Z' && (c < 'a' || (c > 'z' && c <= '~'))))))
|
|
}
|
|
|
|
func isEscapableByte(c byte) bool {
|
|
return isEscapable(rune(c))
|
|
}
|
|
|
|
func isWhitespace(c rune) bool {
|
|
switch c {
|
|
case ' ', '\t', '\n', '\u000b', '\u000c', '\r':
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isWhitespaceByte(c byte) bool {
|
|
return isWhitespace(rune(c))
|
|
}
|
|
|
|
func isNumeric(c rune) bool {
|
|
return c >= '0' && c <= '9'
|
|
}
|
|
|
|
func isNumericByte(c byte) bool {
|
|
return isNumeric(rune(c))
|
|
}
|
|
|
|
func isHex(c rune) bool {
|
|
return isNumeric(c) || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
|
|
}
|
|
|
|
func isHexByte(c byte) bool {
|
|
return isHex(rune(c))
|
|
}
|
|
|
|
func isAlphanumeric(c rune) bool {
|
|
return isNumeric(c) || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
|
|
}
|
|
|
|
func isAlphanumericByte(c byte) bool {
|
|
return isAlphanumeric(rune(c))
|
|
}
|
|
|
|
// isWord returns true if c matches the \w regexp character class
|
|
func isWord(c rune) bool {
|
|
return isAlphanumeric(c) || c == '_'
|
|
}
|
|
|
|
func isWordByte(c byte) bool {
|
|
return isWord(rune(c))
|
|
}
|
|
|
|
func nextNonWhitespace(markdown string, position int) int {
|
|
for offset, c := range []byte(markdown[position:]) {
|
|
if !isWhitespaceByte(c) {
|
|
return position + offset
|
|
}
|
|
}
|
|
return len(markdown)
|
|
}
|
|
|
|
func nextLine(markdown string, position int) (linePosition int, skippedNonWhitespace bool) {
|
|
for i := position; i < len(markdown); i++ {
|
|
c := markdown[i]
|
|
if c == '\r' {
|
|
if i+1 < len(markdown) && markdown[i+1] == '\n' {
|
|
return i + 2, skippedNonWhitespace
|
|
}
|
|
return i + 1, skippedNonWhitespace
|
|
} else if c == '\n' {
|
|
return i + 1, skippedNonWhitespace
|
|
} else if !isWhitespaceByte(c) {
|
|
skippedNonWhitespace = true
|
|
}
|
|
}
|
|
return len(markdown), skippedNonWhitespace
|
|
}
|
|
|
|
func countIndentation(markdown string, r Range) (spaces, bytes int) {
|
|
for i := r.Position; i < r.End; i++ {
|
|
if markdown[i] == ' ' {
|
|
spaces++
|
|
bytes++
|
|
} else if markdown[i] == '\t' {
|
|
spaces += 4
|
|
bytes++
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
func trimLeftSpace(markdown string, r Range) Range {
|
|
s := markdown[r.Position:r.End]
|
|
trimmed := strings.TrimLeftFunc(s, isWhitespace)
|
|
return Range{r.Position, r.End - (len(s) - len(trimmed))}
|
|
}
|
|
|
|
func trimRightSpace(markdown string, r Range) Range {
|
|
s := markdown[r.Position:r.End]
|
|
trimmed := strings.TrimRightFunc(s, isWhitespace)
|
|
return Range{r.Position, r.End - (len(s) - len(trimmed))}
|
|
}
|
|
|
|
func relativeToAbsolutePosition(ranges []Range, position int) int {
|
|
rem := position
|
|
for _, r := range ranges {
|
|
l := r.End - r.Position
|
|
if rem < l {
|
|
return r.Position + rem
|
|
}
|
|
rem -= l
|
|
}
|
|
if len(ranges) == 0 {
|
|
return 0
|
|
}
|
|
return ranges[len(ranges)-1].End
|
|
}
|
|
|
|
func trimBytesFromRanges(ranges []Range, bytes int) (result []Range) {
|
|
rem := bytes
|
|
for _, r := range ranges {
|
|
if rem == 0 {
|
|
result = append(result, r)
|
|
continue
|
|
}
|
|
l := r.End - r.Position
|
|
if rem < l {
|
|
result = append(result, Range{r.Position + rem, r.End})
|
|
rem = 0
|
|
continue
|
|
}
|
|
rem -= l
|
|
}
|
|
return
|
|
}
|
|
|
|
func Parse(markdown string) (*Document, []*ReferenceDefinition) {
|
|
if len(markdown) > MaxLen() {
|
|
return &Document{}, nil
|
|
}
|
|
lines := ParseLines(markdown)
|
|
return ParseBlocks(markdown, lines)
|
|
}
|