fix(classes): sanitize content_html with bluemonday instead of regex checks
- FormatHTML sanitizes the goldmark output with a bluemonday UGC policy extended only with what goldmark and the imported posts and categories use; unsafe markup is stripped, as Winter's Html::clean strips it - HTML the policy keeps whole is stored byte for byte (token comparison) - CheckHTML keeps its signature and 422 contract, refusing HTML the policy would change - the three regexes are gone: the CR-01 bypasses are neutralized and "Data:" or "metadata:" text is no longer refused (WR-01)
This commit is contained in:
@@ -2,14 +2,19 @@ package classes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/renderer/html"
|
||||
xhtml "golang.org/x/net/html"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -17,49 +22,105 @@ var (
|
||||
goldmark.WithExtensions(extension.Footnote, extension.Table),
|
||||
goldmark.WithParserOptions(parser.WithAttribute()),
|
||||
// Winter's Markdown::parse passes raw HTML through, so imported HTML
|
||||
// bodies round-trip unchanged (D-03, PHP parity); rejectUnsafe gates
|
||||
// the rendered result on every write path.
|
||||
// bodies round-trip (D-03, PHP parity); htmlPolicy then sanitizes the
|
||||
// rendered result on every write path.
|
||||
goldmark.WithRendererOptions(html.WithUnsafe()),
|
||||
)
|
||||
|
||||
markdownUnsafeTag = regexp.MustCompile(`(?i)<(?:script|iframe|object|embed)\b`)
|
||||
markdownEventHandler = regexp.MustCompile(`(?i)\son[a-z]+\s*=`)
|
||||
markdownDangerousURL = regexp.MustCompile(`(?i)(?:javascript|vbscript|data):`)
|
||||
// htmlPolicy is the allow-list for stored journal HTML.
|
||||
htmlPolicy = newHTMLPolicy()
|
||||
)
|
||||
|
||||
// newHTMLPolicy extends bluemonday's UGC policy only with what goldmark
|
||||
// renders and what the imported posts and categories use.
|
||||
func newHTMLPolicy() *bluemonday.Policy {
|
||||
p := bluemonday.UGCPolicy()
|
||||
// Winter neither adds nor requires rel="nofollow", so links keep their
|
||||
// stored form.
|
||||
p.RequireNoFollowOnLinks(false)
|
||||
// Imported author footers: <footer class=... itemscope itemtype=...>.
|
||||
p.AllowElements("footer")
|
||||
p.AllowAttrs("class").Matching(bluemonday.SpaceSeparatedTokens).Globally()
|
||||
p.AllowAttrs("itemscope").Matching(regexp.MustCompile(`^(|itemscope)$`)).Globally()
|
||||
p.AllowAttrs("itemtype").Matching(regexp.MustCompile(`^https?://schema\.org/[A-Za-z]+$`)).Globally()
|
||||
p.AllowAttrs("itemprop").Matching(bluemonday.SpaceSeparatedTokens).Globally()
|
||||
// Inline styles in imported footers and goldmark's table cell alignment.
|
||||
// bluemonday validates each property value.
|
||||
p.AllowStyles("color", "font-size", "font-weight", "text-align").Globally()
|
||||
// goldmark's footnote markup.
|
||||
p.AllowAttrs("role").Matching(regexp.MustCompile(`^doc-(noteref|endnotes|backlink)$`)).OnElements("a", "div")
|
||||
// Pasted chat transcripts in imported category bodies: dir="auto" (the
|
||||
// UGC policy allows only ltr and rtl) and two data attributes.
|
||||
p.AllowAttrs("dir").Matching(regexp.MustCompile(`(?i)^(ltr|rtl|auto)$`)).Globally()
|
||||
p.AllowAttrs("data-message-author-role", "data-message-id").
|
||||
Matching(regexp.MustCompile(`^[A-Za-z0-9_-]+$`)).OnElements("div")
|
||||
return p
|
||||
}
|
||||
|
||||
// FormatHTML converts journal post markdown to stored HTML using goldmark
|
||||
// footnote, table, and attribute extensions. Raw HTML in the source passes
|
||||
// through unchanged; the result then goes through the same rejectUnsafe gate
|
||||
// as cabana.RenderMarkdown. It does not call cabana.RenderMarkdown, whose
|
||||
// admin preview stays without unsafe HTML.
|
||||
// through the renderer, as it does in Winter's Markdown::parse. The result is
|
||||
// then sanitized with a bluemonday allow-list that strips, as Winter's
|
||||
// Html::clean does, what it does not allow: script, style, iframe, object,
|
||||
// embed, form, base and meta elements, event handler attributes, and URLs
|
||||
// other than http, https, mailto and relative ones. HTML that the policy
|
||||
// keeps whole is returned byte for byte, so imported bodies round-trip. It
|
||||
// does not call cabana.RenderMarkdown, whose admin preview stays without
|
||||
// unsafe HTML.
|
||||
func FormatHTML(src string) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := markdownEngine.Convert([]byte(strings.TrimSpace(src)), &buf); err != nil {
|
||||
return "", fmt.Errorf("journal: markdown: %w", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if err := rejectUnsafe(out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
out, _ := sanitizeHTML(buf.String())
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CheckHTML applies the FormatHTML safety gate to HTML that is stored as is,
|
||||
// such as category content. It refuses raw script, iframe, object and embed
|
||||
// tags, event handler attributes and javascript, vbscript or data URL schemes.
|
||||
// CheckHTML gates HTML that is stored as is, such as category content. It
|
||||
// uses the FormatHTML allow-list but refuses instead of stripping: doc is
|
||||
// refused when the policy would remove or change any element, attribute or
|
||||
// text in it.
|
||||
func CheckHTML(doc string) error {
|
||||
return rejectUnsafe(doc)
|
||||
}
|
||||
|
||||
func rejectUnsafe(doc string) error {
|
||||
if markdownUnsafeTag.MatchString(doc) {
|
||||
return fmt.Errorf("journal: rendered HTML contains raw unsafe tags")
|
||||
}
|
||||
if markdownEventHandler.MatchString(doc) {
|
||||
return fmt.Errorf("journal: rendered HTML contains event handlers")
|
||||
}
|
||||
if markdownDangerousURL.MatchString(doc) {
|
||||
return fmt.Errorf("journal: rendered HTML contains a dangerous URL scheme")
|
||||
if _, changed := sanitizeHTML(doc); changed {
|
||||
return errors.New("journal: HTML contains elements, attributes or URLs that are not allowed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sanitizeHTML returns doc unchanged when htmlPolicy keeps all of it, and the
|
||||
// policy's output with changed set otherwise. Keeping all of it means doc and
|
||||
// the policy output tokenize to the same HTML tokens, so an output that only
|
||||
// re-escapes text or attribute values is not a change.
|
||||
func sanitizeHTML(doc string) (string, bool) {
|
||||
clean := htmlPolicy.Sanitize(doc)
|
||||
if clean == doc || sameTokens(doc, clean) {
|
||||
return doc, false
|
||||
}
|
||||
return clean, true
|
||||
}
|
||||
|
||||
// sameTokens reports whether a and b tokenize to the same sequence of HTML
|
||||
// tokens: type, tag name or decoded text, and the set of decoded attributes.
|
||||
func sameTokens(a, b string) bool {
|
||||
ta, oka := htmlTokens(a)
|
||||
tb, okb := htmlTokens(b)
|
||||
return oka && okb && slices.Equal(ta, tb)
|
||||
}
|
||||
|
||||
func htmlTokens(doc string) ([]string, bool) {
|
||||
var out []string
|
||||
z := xhtml.NewTokenizer(strings.NewReader(doc))
|
||||
for {
|
||||
tt := z.Next()
|
||||
if tt == xhtml.ErrorToken {
|
||||
return out, errors.Is(z.Err(), io.EOF)
|
||||
}
|
||||
t := z.Token()
|
||||
attrs := make([]string, 0, len(t.Attr))
|
||||
for _, a := range t.Attr {
|
||||
attrs = append(attrs, a.Namespace+":"+a.Key+"="+a.Val)
|
||||
}
|
||||
slices.Sort(attrs)
|
||||
out = append(out, fmt.Sprintf("%d|%s|%q", tt, t.Data, attrs))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user