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