Files
dhkatzandClaude Opus 5.5 9bdc3ff455 Add pack, cfl and safehtml
pack loads local product packs (format 1): t5de-product.json and the product's files, as product.chkn or
files/, with index.xml's rating, type, gender and parent product. cfl writes and reads IMVU's product
container, uncompressed. safehtml cleans product descriptions by an allowlist (bluemonday).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 02:52:59 -07:00

142 lines
5.0 KiB
Go

// Package safehtml makes product descriptions safe to show anywhere, including the client's privileged pages.
//
// Descriptions are cleaned by an allowlist: text formatting, lists, simple tables, headings, links and images,
// and a few styles with checked values. Everything else is removed.
package safehtml
import (
"net/url"
"regexp"
"strings"
"github.com/microcosm-cc/bluemonday"
"golang.org/x/net/html"
)
// MaxDescription is the most bytes a description may have. Description doesn't check it: callers validate
// descriptions when they're saved.
const MaxDescription = 20000
// LinkRel is the rel attribute every link gets.
const LinkRel = "nofollow ugc noopener noreferrer"
var (
color = `#[0-9a-f]{3,4}|#[0-9a-f]{6}|#[0-9a-f]{8}|[a-z]{1,30}|rgba?\(\s*\d{1,3}%?\s*,\s*\d{1,3}%?\s*,\s*\d{1,3}%?\s*(,\s*(0|1|0?\.\d{1,3})\s*)?\)`
colorValue = regexp.MustCompile(`^(?i)(` + color + `)$`)
// font's color attribute: CSS colors, and hex without '#', which browsers also take.
fontColor = regexp.MustCompile(`^(?i)(` + color + `|[0-9a-f]{6})$`)
fontSize = regexp.MustCompile(`^[+-]?[1-7]$`)
cssFontSize = regexp.MustCompile(`^(?i)(\d{1,3}(\.\d{1,2})?(px|pt|em|%)|0|xx-small|x-small|small|medium|large|x-large|xx-large|smaller|larger)$`)
fontWeight = regexp.MustCompile(`^(?i)(normal|bold|bolder|lighter|[1-9]00)$`)
fontStyle = regexp.MustCompile(`^(?i)(normal|italic)$`)
textAlign = regexp.MustCompile(`^(?i)(left|right|center|justify)$`)
textDecoration = regexp.MustCompile(`^(?i)(none|underline|line-through)$`)
smallInt = regexp.MustCompile(`^[1-9][0-9]{0,3}$`)
)
var policy = newPolicy()
func newPolicy() *bluemonday.Policy {
p := bluemonday.NewPolicy()
p.AllowElements("b", "i", "u", "s", "em", "strong", "small", "big", "sub", "sup", "font", "span", "div", "p",
"br", "hr", "center", "ul", "ol", "li", "table", "thead", "tbody", "tr", "td", "th",
"h1", "h2", "h3", "h4", "h5", "h6", "a", "img", "blockquote")
// bluemonday drops elements without attributes unless they are on its list, which lacks big.
p.AllowNoAttrs().OnElements("big")
p.AllowAttrs("color").Matching(fontColor).OnElements("font")
p.AllowAttrs("size").Matching(fontSize).OnElements("font")
p.AllowAttrs("colspan", "rowspan").Matching(smallInt).OnElements("td", "th")
p.AllowAttrs("href", "title").OnElements("a")
p.AllowAttrs("src", "alt", "title").OnElements("img")
p.AllowAttrs("width", "height").Matching(smallInt).OnElements("img")
p.AllowURLSchemes("http", "https")
p.RequireParseableURLs(true)
p.AllowRelativeURLs(false)
p.AllowStyles("color", "background-color").Matching(colorValue).Globally()
p.AllowStyles("font-size").Matching(cssFontSize).Globally()
p.AllowStyles("font-weight").Matching(fontWeight).Globally()
p.AllowStyles("font-style").Matching(fontStyle).Globally()
p.AllowStyles("text-align").Matching(textAlign).Globally()
p.AllowStyles("text-decoration").Matching(textDecoration).Globally()
return p
}
// Description returns description cleaned by the allowlist. Images keep their https addresses.
func Description(description string) string {
return DescriptionWithImages(description, nil)
}
// DescriptionWithImages is Description with each image's https address passed to rewrite, which returns the
// address to show instead, or false to drop the image. A nil rewrite keeps the addresses.
func DescriptionWithImages(description string, rewrite func(src string) (string, bool)) string {
clean := policy.Sanitize(description)
// bluemonday has removed everything unsafe; this pass only adds fixed link attributes and checks images.
var b strings.Builder
z := html.NewTokenizer(strings.NewReader(clean))
for {
if z.Next() == html.ErrorToken {
return b.String()
}
t := z.Token()
if t.Type == html.StartTagToken || t.Type == html.SelfClosingTagToken {
switch t.Data {
case "a":
t.Attr = linkAttrs(t.Attr)
case "img":
var ok bool
if t.Attr, ok = imageAttrs(t.Attr, rewrite); !ok {
continue
}
}
}
b.WriteString(t.String())
}
}
func linkAttrs(attrs []html.Attribute) []html.Attribute {
for _, a := range attrs {
if a.Key == "href" {
return append(attrs,
html.Attribute{Key: "rel", Val: LinkRel},
html.Attribute{Key: "target", Val: "_blank"})
}
}
return attrs
}
func imageAttrs(attrs []html.Attribute, rewrite func(string) (string, bool)) ([]html.Attribute, bool) {
for i, a := range attrs {
if a.Key != "src" {
continue
}
if !isHTTPS(a.Val) {
return nil, false
}
if rewrite != nil {
src, ok := rewrite(a.Val)
if !ok || !isWebURL(src) {
return nil, false
}
attrs[i].Val = src
}
return attrs, true
}
return nil, false
}
func isHTTPS(s string) bool {
u, err := url.Parse(s)
return err == nil && u.Scheme == "https" && u.Host != ""
}
// isWebURL checks a rewritten image address: the image proxy can be http on the local machine.
func isWebURL(s string) bool {
u, err := url.Parse(s)
return err == nil && (u.Scheme == "https" || u.Scheme == "http") && u.Host != "" &&
!strings.ContainsAny(s, " \t\r\n")
}