- 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)
127 lines
4.8 KiB
Go
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))
|
|
}
|
|
}
|