Files
sm-journal-plugin/classes/format_html.go
Jakub Zych a3551f2bdf 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)
2026-10-09 23:35:23 +02:00

127 lines
4.8 KiB
Go

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 (
markdownEngine = goldmark.New(
goldmark.WithExtensions(extension.Footnote, extension.Table),
goldmark.WithParserOptions(parser.WithAttribute()),
// Winter's Markdown::parse passes raw HTML through, so imported HTML
// bodies round-trip (D-03, PHP parity); htmlPolicy then sanitizes the
// rendered result on every write path.
goldmark.WithRendererOptions(html.WithUnsafe()),
)
// 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 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, _ := sanitizeHTML(buf.String())
return out, nil
}
// 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 {
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))
}
}