// Package markup checks HTML and SVG that comes from people other than the
// site's maintainers (packs, icons) for anything that would run code in a
// visitor's browser or send them elsewhere unexpectedly.
package markup
import (
"fmt"
"io"
"strings"
"text/template/parse"
"golang.org/x/net/html"
)
// A pack's templates are checked as the HTML they'll produce, not with a
// pattern: the template is parsed, every action ({{ … }}) becomes a
// placeholder, and the result is read by an HTML tokenizer. So a script
// split by a template comment (), a handler built from an
// action (on{{.x}}click=), an entity-encoded javascript: link or an
// attribute without spaces () all show up for what
// they are.
const placeholder = "hdpackx"
// forbidden elements: they run code, load other pages or documents, or
// change where links and forms go.
var forbidden = map[string]bool{
"script": true, "iframe": true, "frame": true, "frameset": true, "object": true, "embed": true, "applet": true,
"base": true, "meta": true, "portal": true, "noscript": true,
}
// urlAttrs hold addresses; their values must be web, mail or relative links.
var urlAttrs = map[string]bool{
"href": true, "src": true, "action": true, "formaction": true, "poster": true, "data": true, "background": true,
"cite": true, "longdesc": true, "usemap": true, "xlink:href": true, "ping": true, "manifest": true, "codebase": true,
}
// Template lists what in a Go template would run code in a visitor's
// browser or send them elsewhere unexpectedly.
func Template(src string) ([]string, error) {
trees, err := parse.Parse("pack", src, "", "", map[string]any{})
if err != nil {
// Unknown functions are expected (asset, markdownify, …): parse
// again without checking them.
t := parse.New("pack")
t.Mode = parse.SkipFuncCheck
trees = map[string]*parse.Tree{}
if _, err := t.Parse(src, "", "", trees); err != nil {
return nil, fmt.Errorf("the template doesn't parse: %w", err)
}
}
var b strings.Builder
for _, tr := range trees {
if tr.Root != nil {
flatten(&b, tr.Root)
}
}
return scan(b.String(), false), nil
}
// SVG lists the same for an SVG file, which may also not reach outside
// itself (href only to "#id" in the same file) or embed HTML or styles.
func SVG(src string) []string {
return scan(src, true)
}
// flatten writes a template's text with every action as the placeholder.
// Control structures contribute every branch, so whatever the template
// could produce is checked.
func flatten(b *strings.Builder, n parse.Node) {
switch n := n.(type) {
case *parse.ListNode:
if n == nil {
return
}
for _, c := range n.Nodes {
flatten(b, c)
}
case *parse.TextNode:
b.Write(n.Text)
case *parse.ActionNode, *parse.TemplateNode:
b.WriteString(placeholder)
case *parse.IfNode:
flatten(b, n.List)
flatten(b, n.ElseList)
case *parse.RangeNode:
flatten(b, n.List)
flatten(b, n.ElseList)
case *parse.WithNode:
flatten(b, n.List)
flatten(b, n.ElseList)
}
}
func scan(markup string, svg bool) []string {
var problems []string
z := html.NewTokenizer(strings.NewReader(markup))
for {
tt := z.Next()
if tt == html.ErrorToken {
if z.Err() != io.EOF {
problems = append(problems, "the markup can't be read")
}
return problems
}
if tt != html.StartTagToken && tt != html.SelfClosingTagToken {
continue
}
tok := z.Token()
name := strings.ToLower(tok.Data)
if strings.Contains(name, placeholder) {
problems = append(problems, "an element whose name comes from a template action")
continue
}
if forbidden[name] || (svg && (name == "foreignobject" || name == "style")) {
problems = append(problems, fmt.Sprintf("a <%s> element", name))
}
if name == "link" && !ownStylesheet(tok.Attr) {
problems = append(problems, "a other than a stylesheet on the site ({{ asset \"packs//x.css\" }})")
}
for _, a := range tok.Attr {
key := strings.ToLower(a.Key)
switch {
case strings.Contains(key, placeholder):
problems = append(problems, fmt.Sprintf("an attribute on <%s> whose name comes from a template action", name))
case strings.HasPrefix(key, "on"):
problems = append(problems, fmt.Sprintf("an event handler (%s) on <%s>", key, name))
case key == "srcdoc" || key == "http-equiv":
problems = append(problems, fmt.Sprintf("%s on <%s>", key, name))
case svg && (key == "href" || key == "xlink:href" || key == "src"):
if v := strings.TrimSpace(a.Val); !strings.HasPrefix(v, "#") {
problems = append(problems, fmt.Sprintf("a %s on <%s> that reaches outside the file (%q)", key, name, a.Val))
}
case urlAttrs[key] || key == "srcset":
if !safeURL(a.Val) {
problems = append(problems, fmt.Sprintf("a %s on <%s> that isn't a web, mail or relative link (%q)", key, name, a.Val))
}
}
}
}
}
// safeURL accepts relative links, web and mail links, and values that are a
// single template action (which html/template itself filters). An action
// after a fixed start ("java{{.x}}script:") is refused, since the template
// escaper can't see the whole scheme.
func safeURL(v string) bool {
v = strings.TrimSpace(v)
if v == "" || v == placeholder {
return true
}
lower := strings.ToLower(v)
for _, p := range []string{"https://", "http://", "mailto:", "/", "#", "?", "./", "../"} {
if strings.HasPrefix(lower, p) {
return true
}
}
if strings.HasPrefix(v, placeholder) {
// "{{ asset "x" }}" or "{{ .Href }}" first: html/template filters the
// scheme of what the action produces.
return true
}
// No scheme at all (a relative path like "img/x.png") is fine; anything
// with a colon before the first slash is a scheme we don't allow.
colon := strings.IndexByte(v, ':')
slash := strings.IndexAny(v, "/?#")
return colon < 0 || (slash >= 0 && slash < colon)
}
// ownStylesheet: a to a file on the site itself,
// which is how a pack brings its styles.
func ownStylesheet(attrs []html.Attribute) bool {
rel, href := "", ""
for _, a := range attrs {
switch strings.ToLower(a.Key) {
case "rel":
rel = strings.ToLower(strings.TrimSpace(a.Val))
case "href":
href = strings.TrimSpace(a.Val)
}
}
return rel == "stylesheet" && (href == placeholder || (strings.HasPrefix(href, "/") && !strings.HasPrefix(href, "//")))
}