- seaglass (new module): bluemonday v1.0.27 policy ported from Journal without the Blog Editor rules, byte-stable token comparison, SanitizeReport, Sanitize, Check, Removals - cabana: type richeditor (common keys plus size); every create and update value sanitized before Fill, non-string values are a 422, removals become meta.notices; settings and relation forms refuse the type - backend lang richeditor.removed_* and markup_changed (en, pl plurals) - TestSeaglassSmoke, TestCompanionsRichEditorSmoke, README and docs
399 lines
11 KiB
Go
399 lines
11 KiB
Go
// Package seaglass is the framework's HTML sanitizer: a bluemonday allowlist
|
|
// policy for stored rich text, with a byte-stable comparison so HTML the
|
|
// policy keeps whole is returned unchanged, and a report of what it removed.
|
|
package seaglass
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/microcosm-cc/bluemonday"
|
|
xhtml "golang.org/x/net/html"
|
|
)
|
|
|
|
// policy is the allowlist for stored rich text. It is safe for concurrent
|
|
// use: bluemonday policies are read-only once built.
|
|
var policy = newPolicy()
|
|
|
|
// newPolicy is bluemonday's UGC policy with the additions stored rich text
|
|
// needs: links keep their form (no rel="nofollow" is added), tel: links are
|
|
// allowed next to http, https, mailto and relative URLs, and the class, dir,
|
|
// microdata, footnote role and inline style attributes imported WinterCMS
|
|
// content uses. Scripts, styles, frames, forms, embedded objects, SVG and
|
|
// MathML, comments, every on* attribute, target and rel, and every other URL
|
|
// scheme are removed.
|
|
func newPolicy() *bluemonday.Policy {
|
|
p := bluemonday.UGCPolicy()
|
|
p.RequireNoFollowOnLinks(false)
|
|
p.AllowURLSchemes("tel")
|
|
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()
|
|
p.AllowStyles("color", "font-size", "font-weight", "text-align").Globally()
|
|
p.AllowAttrs("role").Matching(regexp.MustCompile(`^doc-(noteref|endnotes|backlink)$`)).OnElements("a", "div")
|
|
p.AllowAttrs("dir").Matching(regexp.MustCompile(`(?i)^(ltr|rtl|auto)$`)).Globally()
|
|
return p
|
|
}
|
|
|
|
// maxRemovalNames is how many distinct names a Removals list keeps.
|
|
const maxRemovalNames = 10
|
|
|
|
// Removals reports what SanitizeReport removed. Elements and Attributes are
|
|
// the distinct removed element and attribute names in first-seen order, at
|
|
// most ten each (a cut list ends with "…"); 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 repeats a name the tag
|
|
// already has, or when the policy changed its value (a style that lost a
|
|
// property counts under "style"). Comments counts removed HTML comments.
|
|
// Changed is true whenever the returned HTML differs from the input; it is the
|
|
// only field set when the policy changed markup none of the others describe.
|
|
type Removals struct {
|
|
Elements []string
|
|
ElementCount int
|
|
Attributes []string
|
|
AttributeCount int
|
|
Comments int
|
|
Changed bool
|
|
}
|
|
|
|
func (r *Removals) addElement(name string) {
|
|
r.ElementCount++
|
|
r.Elements = addName(r.Elements, name)
|
|
}
|
|
|
|
func (r *Removals) addAttribute(name string) {
|
|
r.AttributeCount++
|
|
r.Attributes = addName(r.Attributes, name)
|
|
}
|
|
|
|
func addName(names []string, name string) []string {
|
|
if slices.Contains(names, name) {
|
|
return names
|
|
}
|
|
if len(names) == maxRemovalNames {
|
|
return append(names, "…")
|
|
}
|
|
if len(names) > maxRemovalNames {
|
|
return names
|
|
}
|
|
return append(names, name)
|
|
}
|
|
|
|
// Sanitize returns html filtered through the policy: html itself, byte for
|
|
// byte, when the policy keeps all of it, otherwise the policy's output.
|
|
func Sanitize(html string) string {
|
|
out, _ := SanitizeReport(html)
|
|
return out
|
|
}
|
|
|
|
// SanitizeReport is Sanitize with a report of what the policy removed. When
|
|
// html and the policy's output tokenize to the same HTML tokens (same type,
|
|
// tag name, decoded text and set of decoded attributes), html is returned
|
|
// unchanged with a zero Removals, so re-escaping alone is never a change.
|
|
// Otherwise the policy's output is returned with Changed set and the removed
|
|
// elements, attributes and comments, found by aligning the start tags of html
|
|
// with those of the output in order (the policy only removes, it never adds
|
|
// or reorders).
|
|
func SanitizeReport(html string) (string, Removals) {
|
|
clean, changed := sanitize(html)
|
|
if !changed || clean == html {
|
|
return html, Removals{}
|
|
}
|
|
report := removalReport(html, clean)
|
|
report.Changed = true
|
|
return clean, report
|
|
}
|
|
|
|
// Check returns an error when the policy would change any element, attribute
|
|
// or text of html, and nil when it keeps html whole.
|
|
func Check(html string) error {
|
|
if _, changed := sanitize(html); changed {
|
|
return errors.New("seaglass: HTML contains elements, attributes or URLs that are not allowed")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// sanitize returns doc unchanged when the policy keeps all of it, and the
|
|
// policy's output with changed set otherwise. 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 sanitize(doc string) (string, bool) {
|
|
clean := policy.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. 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))
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
func removalReport(doc, clean string) Removals {
|
|
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 Removals
|
|
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
|
|
}
|
|
|
|
// 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)
|
|
}
|