8833a2b8f8
The sanitizer walked only element nodes, so a script hidden in a downlevel IE conditional comment slipped through. golang.org/x/net/html parses <!--[if lt IE 9]><script src="..."></script><![endif]--> as a single comment node whose data holds the raw markup, so the element walk never sees the <script> and it rendered straight back out, a live-CDN script reference left sitting in a page kage promises is inert. Plenty of older docs sites (clojure.org, cordova.apache.org, and the async library docs among them) still ship html5shiv, respond.js, or placeholders.js this way. Drop conditional comments in the walk. The downlevel-hidden form is one comment and goes whole; the downlevel-revealed form keeps its content, which lives in sibling nodes, and loses only the two markers.
249 lines
7.4 KiB
Go
249 lines
7.4 KiB
Go
// Package sanitize removes every trace of JavaScript from an HTML document so
|
|
// the saved page is inert: a photograph, not a program.
|
|
//
|
|
// It parses with golang.org/x/net/html, walks the tree, and deletes scripts,
|
|
// event handlers, javascript: URLs, downlevel IE conditional comments (which
|
|
// can smuggle a <script> past an element-only walk), and the dead
|
|
// preconnect/preload hints that mean nothing offline — while leaving styles,
|
|
// images, fonts, forms, and all semantic markup untouched so the layout
|
|
// survives intact.
|
|
package sanitize
|
|
|
|
import (
|
|
"bytes"
|
|
"strings"
|
|
|
|
"golang.org/x/net/html"
|
|
"golang.org/x/net/html/atom"
|
|
)
|
|
|
|
// Options tune a few edge behaviours; the zero value is the safe default
|
|
// (scripts and noscript removed, meta-refresh removed).
|
|
type Options struct {
|
|
// KeepNoscript unwraps <noscript> content into the document instead of
|
|
// deleting it, for sites whose real content hides behind a JS check.
|
|
KeepNoscript bool
|
|
// KeepMetaRefresh preserves a plain timed <meta http-equiv="refresh">
|
|
// (a JS-target refresh is always removed).
|
|
KeepMetaRefresh bool
|
|
// Banner, when non-empty, is inserted as an HTML comment at the top of the
|
|
// document.
|
|
Banner string
|
|
}
|
|
|
|
// Report counts what was removed, for the run summary and for tests.
|
|
type Report struct {
|
|
ScriptsRemoved int
|
|
HandlersRemoved int
|
|
NoscriptRemoved int
|
|
NoscriptUnwrapped int
|
|
JSURLsNeutralized int
|
|
MetaRefreshRemoved int
|
|
DeadLinksRemoved int
|
|
CondCommentsRemoved int
|
|
}
|
|
|
|
// jsURLAttrs are attributes whose value may be a javascript: URL.
|
|
var jsURLAttrs = map[string]bool{
|
|
"href": true, "src": true, "action": true, "formaction": true,
|
|
"poster": true, "data": true, "background": true, "xlink:href": true,
|
|
}
|
|
|
|
// Strip parses doc, removes all JavaScript, and returns the rewritten HTML plus
|
|
// a Report. A parse error is returned unchanged to the caller.
|
|
func Strip(doc []byte, opts Options) ([]byte, Report, error) {
|
|
root, err := html.Parse(bytes.NewReader(doc))
|
|
if err != nil {
|
|
return nil, Report{}, err
|
|
}
|
|
rep := CleanTree(root, opts)
|
|
var buf bytes.Buffer
|
|
if err := html.Render(&buf, root); err != nil {
|
|
return nil, rep, err
|
|
}
|
|
return buf.Bytes(), rep, nil
|
|
}
|
|
|
|
// CleanTree removes all JavaScript from an already-parsed document in place and
|
|
// returns the Report. The cloner uses this so the HTML is parsed only once and
|
|
// shared with the asset rewriter.
|
|
func CleanTree(root *html.Node, opts Options) Report {
|
|
var rep Report
|
|
clean(root, opts, &rep)
|
|
if opts.Banner != "" {
|
|
insertBanner(root, opts.Banner)
|
|
}
|
|
return rep
|
|
}
|
|
|
|
// clean walks n's children, removing or scrubbing each before recursing.
|
|
func clean(n *html.Node, opts Options, rep *Report) {
|
|
var next *html.Node
|
|
for c := n.FirstChild; c != nil; c = next {
|
|
next = c.NextSibling
|
|
if c.Type == html.CommentNode {
|
|
// A downlevel IE conditional comment (<!--[if lt IE 9]>...<![endif]-->)
|
|
// parses as one comment whose data holds raw markup — a <script src>
|
|
// among it. The element walk never sees that script, so drop the whole
|
|
// comment. Downlevel-revealed content lives in sibling nodes, not the
|
|
// comment data, so it is untouched.
|
|
if isConditionalComment(c.Data) {
|
|
n.RemoveChild(c)
|
|
rep.CondCommentsRemoved++
|
|
}
|
|
continue
|
|
}
|
|
if c.Type == html.ElementNode {
|
|
switch c.DataAtom {
|
|
case atom.Script:
|
|
n.RemoveChild(c)
|
|
rep.ScriptsRemoved++
|
|
continue
|
|
case atom.Noscript:
|
|
if opts.KeepNoscript {
|
|
unwrapNoscript(n, c)
|
|
rep.NoscriptUnwrapped++
|
|
} else {
|
|
n.RemoveChild(c)
|
|
rep.NoscriptRemoved++
|
|
}
|
|
continue
|
|
case atom.Meta:
|
|
if isMetaRefresh(c) && (!opts.KeepMetaRefresh || isJSRefresh(c)) {
|
|
n.RemoveChild(c)
|
|
rep.MetaRefreshRemoved++
|
|
continue
|
|
}
|
|
case atom.Link:
|
|
if isDeadLink(c) {
|
|
n.RemoveChild(c)
|
|
rep.DeadLinksRemoved++
|
|
continue
|
|
}
|
|
}
|
|
stripHandlers(c, rep)
|
|
neutralizeJSURLs(c, rep)
|
|
}
|
|
clean(c, opts, rep)
|
|
}
|
|
}
|
|
|
|
// stripHandlers removes every on* event-handler attribute from n.
|
|
func stripHandlers(n *html.Node, rep *Report) {
|
|
kept := n.Attr[:0]
|
|
for _, a := range n.Attr {
|
|
if len(a.Key) > 2 && strings.HasPrefix(strings.ToLower(a.Key), "on") {
|
|
rep.HandlersRemoved++
|
|
continue
|
|
}
|
|
kept = append(kept, a)
|
|
}
|
|
n.Attr = kept
|
|
}
|
|
|
|
// neutralizeJSURLs replaces javascript: URLs: links become "#", other carriers
|
|
// lose the attribute entirely.
|
|
func neutralizeJSURLs(n *html.Node, rep *Report) {
|
|
kept := n.Attr[:0]
|
|
for _, a := range n.Attr {
|
|
key := strings.ToLower(a.Key)
|
|
if jsURLAttrs[key] && strings.HasPrefix(strings.ToLower(strings.TrimSpace(a.Val)), "javascript:") {
|
|
rep.JSURLsNeutralized++
|
|
if key == "href" {
|
|
a.Val = "#"
|
|
kept = append(kept, a)
|
|
}
|
|
// non-href carriers: drop the attribute.
|
|
continue
|
|
}
|
|
kept = append(kept, a)
|
|
}
|
|
n.Attr = kept
|
|
}
|
|
|
|
// isMetaRefresh reports whether n is a <meta http-equiv="refresh">.
|
|
func isMetaRefresh(n *html.Node) bool {
|
|
return strings.EqualFold(attr(n, "http-equiv"), "refresh")
|
|
}
|
|
|
|
// isJSRefresh reports whether a meta-refresh target is a javascript: URL.
|
|
func isJSRefresh(n *html.Node) bool {
|
|
return strings.Contains(strings.ToLower(attr(n, "content")), "javascript:")
|
|
}
|
|
|
|
// isDeadLink reports whether a <link> is a resource hint that is useless or
|
|
// script-bound offline: preconnect, dns-prefetch, modulepreload, or a
|
|
// preload/prefetch that targets a script.
|
|
func isDeadLink(n *html.Node) bool {
|
|
for r := range strings.FieldsSeq(strings.ToLower(attr(n, "rel"))) {
|
|
switch r {
|
|
case "preconnect", "dns-prefetch", "modulepreload":
|
|
return true
|
|
case "preload", "prefetch":
|
|
as := strings.ToLower(attr(n, "as"))
|
|
href := strings.ToLower(attr(n, "href"))
|
|
if as == "script" || strings.HasSuffix(href, ".js") {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// isConditionalComment reports whether a comment's data is a downlevel IE
|
|
// conditional-comment marker. Both the downlevel-hidden form (the whole
|
|
// "[if lt IE 9]>...<![endif]" in one comment) and the two markers of the
|
|
// downlevel-revealed form ("[if gte IE 9]><!" and "<![endif]") match, so the
|
|
// markers are stripped while any revealed content, which sits in sibling
|
|
// nodes, stays.
|
|
func isConditionalComment(data string) bool {
|
|
d := strings.TrimSpace(data)
|
|
return strings.HasPrefix(d, "[if") ||
|
|
strings.HasPrefix(d, "<![endif]") ||
|
|
strings.HasPrefix(d, "[endif]")
|
|
}
|
|
|
|
// unwrapNoscript replaces a <noscript> with its content. Because x/net/html
|
|
// parses noscript content as raw text (scripting enabled), the text is
|
|
// re-parsed as a fragment in the parent's context and spliced in before the
|
|
// noscript node, which is then removed.
|
|
func unwrapNoscript(parent, ns *html.Node) {
|
|
var raw strings.Builder
|
|
for c := ns.FirstChild; c != nil; c = c.NextSibling {
|
|
if c.Type == html.TextNode {
|
|
raw.WriteString(c.Data)
|
|
}
|
|
}
|
|
frag, err := html.ParseFragment(strings.NewReader(raw.String()), &html.Node{
|
|
Type: html.ElementNode,
|
|
Data: "body",
|
|
DataAtom: atom.Body,
|
|
})
|
|
if err == nil {
|
|
for _, fn := range frag {
|
|
parent.InsertBefore(fn, ns)
|
|
}
|
|
}
|
|
parent.RemoveChild(ns)
|
|
}
|
|
|
|
// insertBanner prepends an HTML comment to the document.
|
|
func insertBanner(root *html.Node, text string) {
|
|
c := &html.Node{Type: html.CommentNode, Data: " " + text + " "}
|
|
if root.FirstChild != nil {
|
|
root.InsertBefore(c, root.FirstChild)
|
|
} else {
|
|
root.AppendChild(c)
|
|
}
|
|
}
|
|
|
|
// attr returns the value of n's attribute key (case-insensitive), or "".
|
|
func attr(n *html.Node, key string) string {
|
|
for _, a := range n.Attr {
|
|
if strings.EqualFold(a.Key, key) {
|
|
return a.Val
|
|
}
|
|
}
|
|
return ""
|
|
}
|