Files
mailgoserver/internal/webui/remote_images.go
T

80 lines
2.5 KiB
Go

package webui
import (
"strings"
"golang.org/x/net/html"
)
// stripRemoteImages walks already-sanitized HTML (htmlBodyPolicy has already removed
// scripts/event handlers/etc. — this never runs on untrusted-for-XSS-purposes input)
// and neutralizes any <img src="..."> that isn't a data: URI, renaming it to
// data-blocked-src so the browser never fetches it — a classic tracking-pixel/
// read-receipt vector otherwise. Reports whether anything was actually blocked, so
// the caller only shows a "Show images" banner when there's something to reveal.
//
// This runs as a second pass over the DOM rather than trying to make bluemonday's own
// policy conditionally reject remote img src — bluemonday composes URL-scheme rules
// globally per policy (AllowStandardURLs), and UGCPolicy already bakes in "img src
// follows the global scheme allowlist" internally, so cleanly restricting only img
// src to data: URIs while leaving other elements' href/src alone isn't something the
// policy API exposes directly. A dedicated pass keeps the two concerns (XSS
// sanitization vs. privacy-motivated image blocking) independent and easy to reason
// about separately.
func stripRemoteImages(sanitizedHTML string) (cleaned string, blocked bool) {
doc, err := html.Parse(strings.NewReader(sanitizedHTML))
if err != nil {
return sanitizedHTML, false
}
var walk func(*html.Node)
walk = func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "img" {
for i, attr := range n.Attr {
if attr.Key != "src" {
continue
}
if strings.HasPrefix(attr.Val, "data:") {
break
}
n.Attr[i].Key = "data-blocked-src"
blocked = true
break
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(doc)
if !blocked {
return sanitizedHTML, false
}
var buf strings.Builder
// html.Parse wraps a fragment in a full document (html>head,body) — render just
// the body's children back out, matching what was originally passed in (a
// fragment, not a full document).
body := findBody(doc)
if body == nil {
return sanitizedHTML, false
}
for c := body.FirstChild; c != nil; c = c.NextSibling {
if err := html.Render(&buf, c); err != nil {
return sanitizedHTML, false
}
}
return buf.String(), true
}
func findBody(n *html.Node) *html.Node {
if n.Type == html.ElementNode && n.Data == "body" {
return n
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
if b := findBody(c); b != nil {
return b
}
}
return nil
}