// 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)
}