Files
sm-journal-plugin/classes/format_html.go
Jakub Zych 2384a61e38 feat(classes): allow the Blog Editor's own markup in the HTML policy
- figure data-embed ^youtube$ and data-id ^[A-Za-z0-9_-]{11}$ (the embed
  placeholder), img alt with any text, ol start ^[0-9]+$; nothing else
- TestEditorOutputPassesPolicy checks every editor-corpus.json entry byte
  for byte with an empty report, plus the negative cases;
  TestSanitizeHTMLReport pins the removal report
- CheckHTML documented as a predicate no write path refuses with; README
  lists the four additions and the report API
2026-10-10 12:11:29 +02:00

507 lines
16 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")
// Blog Editor output (Phase 16.2 D-05), exactly what the editor writes
// and nothing more; plan 02's editor-corpus.json pins it
// (TestEditorOutputPassesPolicy) and the site layer mirrors these rules.
// The embed placeholder <figure data-embed="youtube" data-id="..."></figure>:
// the provider name and the 11-character video id, on figure only. The
// layer expands it; no iframe is ever stored.
p.AllowAttrs("data-embed").Matching(regexp.MustCompile(`^youtube$`)).OnElements("figure")
p.AllowAttrs("data-id").Matching(regexp.MustCompile(`^[A-Za-z0-9_-]{11}$`)).OnElements("figure")
// The editor's alt field takes free text, which UGC's Paragraph pattern
// cuts at ':', '?', '"' and more. bluemonday accepts an attribute when
// any of its rules matches and escapes the value on output; alt carries
// no URL.
p.AllowAttrs("alt").OnElements("img")
// TipTap's ordered-list input rule writes start when a list does not
// begin at 1.
p.AllowAttrs("start").Matching(regexp.MustCompile(`^[0-9]+$`)).OnElements("ol")
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)
}
return SanitizeHTML(buf.String()), nil
}
// SanitizeHTML filters doc, which is HTML and not markdown, through the
// FormatHTML allow-list and strips what the policy does not allow. HTML the
// policy keeps whole is returned byte for byte. It is the gate for
// content_html supplied without markdown, such as a translation that carries
// only content_html. It is the string half of SanitizeHTMLReport.
func SanitizeHTML(doc string) string {
out, _ := SanitizeHTMLReport(doc)
return out
}
// maxRemovalNames is how many distinct names an HTMLRemovals list keeps.
const maxRemovalNames = 10
// HTMLRemovals reports what SanitizeHTMLReport removed from a document.
// Elements and Attributes are the distinct removed element and attribute
// names in first-seen order, at most ten each; ElementCount and
// AttributeCount count every removal, so they stay exact when a list is cut.
// An attribute counts as removed when the policy dropped it, when it is a
// repeat of a name the tag already has, or when the policy changed its value
// (a style attribute that lost a property counts under "style"). Comments
// counts removed HTML comments. Other is set when the policy changed the
// document in a way none of the other fields describe.
type HTMLRemovals struct {
Elements []string
ElementCount int
Attributes []string
AttributeCount int
Comments int
Other bool
// moreElements and moreAttributes are set when a list was cut.
moreElements bool
moreAttributes bool
}
// Empty reports whether nothing was removed, which is exactly when
// SanitizeHTMLReport returned its input unchanged.
func (r HTMLRemovals) Empty() bool {
return r.ElementCount == 0 && r.AttributeCount == 0 && r.Comments == 0 && !r.Other
}
func (r *HTMLRemovals) addElement(name string) {
r.ElementCount++
r.Elements, r.moreElements = addRemovalName(r.Elements, r.moreElements, name)
}
func (r *HTMLRemovals) addAttribute(name string) {
r.AttributeCount++
r.Attributes, r.moreAttributes = addRemovalName(r.Attributes, r.moreAttributes, name)
}
func addRemovalName(names []string, cut bool, name string) ([]string, bool) {
if slices.Contains(names, name) {
return names, cut
}
if len(names) >= maxRemovalNames {
return names, true
}
return append(names, name), cut
}
// SanitizeHTMLReport is SanitizeHTML with a report of what the policy
// removed. HTML the policy keeps whole is returned byte for byte with an
// empty report. Otherwise the policy's output is returned and the report is
// built by aligning the start tags of doc with those of the output in order
// (the policy only removes, it never adds or reorders): an input tag without
// a counterpart is a removed element, and an attribute of a matched tag that
// the output lacks or holds with another value is a removed attribute.
// Comments are counted on their own. The report is empty exactly when the
// returned string equals doc.
func SanitizeHTMLReport(doc string) (string, HTMLRemovals) {
clean, changed := sanitizeHTML(doc)
if !changed || clean == doc {
return doc, HTMLRemovals{}
}
report := removalReport(doc, clean)
if report.Empty() {
report.Other = true
}
return clean, report
}
// reportTag is one start tag of a document as removalReport sees it.
type reportTag struct {
name string
raw []string // attribute names in source order, repeats included
attrs map[string]string // decoded values, the first of a repeated name
}
// reportTokens returns doc's start tags and its number of comments.
func reportTokens(doc string) ([]reportTag, int) {
var (
tags []reportTag
comments int
)
z := xhtml.NewTokenizer(strings.NewReader(doc))
for {
tt := z.Next()
switch tt {
case xhtml.ErrorToken:
return tags, comments
case xhtml.CommentToken:
comments++
case xhtml.StartTagToken, xhtml.SelfClosingTagToken:
raw := rawAttrNames(z.Raw())
t := z.Token()
attrs := make(map[string]string, len(t.Attr))
for _, a := range t.Attr {
key := a.Key
if a.Namespace != "" {
key = a.Namespace + ":" + a.Key
}
if _, seen := attrs[key]; !seen {
attrs[key] = a.Val
}
}
tags = append(tags, reportTag{name: t.Data, raw: raw, attrs: attrs})
}
}
}
// keeps reports whether out can be the policy's output of in: the same
// element, and every output attribute present in the input with the same
// value, except style, whose properties the policy filters one by one.
func (in reportTag) keeps(out reportTag) bool {
if in.name != out.name {
return false
}
for key, val := range out.attrs {
got, ok := in.attrs[key]
if !ok || (got != val && key != "style") {
return false
}
}
return true
}
// removalReport compares doc with the policy's output clean. It aligns the
// start tags greedily, preferring an output tag whose attributes the input
// tag can have produced; when that leaves output tags unmatched, it aligns
// by element name only, which always succeeds when the output's tags are a
// subsequence of the input's.
func removalReport(doc, clean string) HTMLRemovals {
in, inComments := reportTokens(doc)
out, outComments := reportTokens(clean)
match := alignTags(in, out, reportTag.keeps)
if match == nil {
match = alignTags(in, out, func(a, b reportTag) bool { return a.name == b.name })
}
var r HTMLRemovals
for i, tag := range in {
j := -1
if match != nil {
j = match[i]
}
if j < 0 {
r.addElement(tag.name)
continue
}
kept := out[j]
seen := make(map[string]bool, len(tag.raw))
for _, name := range tag.raw {
if seen[name] {
r.addAttribute(name) // a repeat the tokenizer dropped
continue
}
seen[name] = true
val, ok := kept.attrs[name]
if !ok || val != tag.attrs[name] {
r.addAttribute(name)
}
}
}
if inComments > outComments {
r.Comments = inComments - outComments
}
return r
}
// alignTags matches every tag of out to a tag of in, in order, with the
// first input tag for which same holds. The result maps each input index to
// its output index or -1. It is nil when an output tag stays unmatched.
func alignTags(in, out []reportTag, same func(in, out reportTag) bool) []int {
match := make([]int, len(in))
j := 0
for i := range in {
match[i] = -1
if j < len(out) && same(in[i], out[j]) {
match[i] = j
j++
}
}
if j < len(out) {
return nil
}
return match
}
// FormatTranslation sets attrs["content_html"] for one locale's translated
// post attributes, so a translated content_html is stored under the same
// policy as the host column. When attrs["content"] is not blank, content_html
// is rendered from it with FormatHTML and a supplied content_html is
// replaced, as the host column is always derived from content. Otherwise a
// supplied, non-null content_html is sanitized with SanitizeHTML; a value
// that is not a string is sanitized as its text form. attrs without content
// or content_html is left unchanged.
func FormatTranslation(attrs map[string]any) error {
if attrs == nil {
return nil
}
if content := textOf(attrs["content"]); strings.TrimSpace(content) != "" {
html, err := FormatHTML(content)
if err != nil {
return err
}
attrs["content_html"] = html
return nil
}
if v, has := attrs["content_html"]; has && v != nil {
attrs["content_html"] = SanitizeHTML(textOf(v))
}
return nil
}
func textOf(v any) string {
switch t := v.(type) {
case nil:
return ""
case string:
return t
default:
return fmt.Sprint(t)
}
}
// CheckHTML reports whether the FormatHTML allow-list would remove or
// change any element, attribute or text in doc: it returns an error when it
// would, nil when the policy keeps doc whole. Since Phase 16.2 no admin or
// API write path refuses with it (admin saves strip and report through
// SanitizeHTMLReport, D-10 amended); it is the predicate counterpart of the
// site layer's isSafeJournalHtml, and the policy cross-check tests use 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. A tag that repeats an
// attribute is always a change: the tokenizer keeps only the first of them,
// so the comparison would not see the others in the stored bytes.
func sanitizeHTML(doc string) (string, bool) {
clean := htmlPolicy.Sanitize(doc)
if 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.
// It is false when either repeats an attribute in a tag.
func sameTokens(a, b string) bool {
ta, oka := htmlTokens(a)
if !oka {
return false
}
tb, okb := htmlTokens(b)
return okb && slices.Equal(ta, tb)
}
// htmlTokens tokenizes doc. It is not ok when the tokenizer stops on an
// error other than EOF, or when a start tag's raw attributes differ from the
// attributes the tokenizer kept, which happens when a name repeats.
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)
}
var raw []string
if tt == xhtml.StartTagToken || tt == xhtml.SelfClosingTagToken {
raw = rawAttrNames(z.Raw())
}
t := z.Token()
if tt == xhtml.StartTagToken || tt == xhtml.SelfClosingTagToken {
if len(raw) != len(t.Attr) {
return out, false
}
for i, a := range t.Attr {
if raw[i] != a.Key {
return out, false
}
}
}
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))
}
}
// rawAttrNames returns the ASCII-lowercased name of every attribute in the
// raw start tag, repeats included, in source order. It scans the tag as
// golang.org/x/net/html's tokenizer does (readTagName, readTagAttrKey and
// readTagAttrVal), which drops every repeat of a name it has seen. A scan
// that disagrees with the tokenizer only makes htmlTokens report a change.
func rawAttrNames(raw []byte) []string {
i, n := 2, len(raw) // past '<' and the first byte of the tag name
skipSpace := func() {
for i < n && isHTMLSpace(raw[i]) {
i++
}
}
for i < n {
c := raw[i]
i++
if isHTMLSpace(c) {
break
}
if c == '/' || c == '>' {
i--
break
}
}
var names []string
skipSpace()
for i < n {
if raw[i] == '>' {
break
}
// Attribute name: an '=' that starts it is part of the name.
start, end := i, n
for i < n {
c := raw[i]
i++
if c == '=' && i == start+1 {
continue
}
if c == '=' || c == '/' || c == '>' || isHTMLSpace(c) {
i--
end = i
break
}
}
// Attribute value.
skipSpace()
if i < n {
c := raw[i]
i++
switch {
case c == '/':
case c != '=':
i--
default:
skipSpace()
if i < n {
q := raw[i]
i++
switch q {
case '>':
i--
case '"', '\'':
for i < n {
i++
if raw[i-1] == q {
break
}
}
default:
for i < n {
c := raw[i]
i++
if isHTMLSpace(c) {
break
}
if c == '>' {
i--
break
}
}
}
}
}
}
if end > start {
names = append(names, asciiLower(raw[start:end]))
}
skipSpace()
}
return names
}
func isHTMLSpace(c byte) bool {
return c == ' ' || c == '\n' || c == '\r' || c == '\t' || c == '\f'
}
func asciiLower(b []byte) string {
out := []byte(string(b))
for i, c := range out {
if 'A' <= c && c <= 'Z' {
out[i] = c + 'a' - 'A'
}
}
return string(out)
}