diff --git a/server/dto/parsemail/email.go b/server/dto/parsemail/email.go
index 0b4cc72c..8a79db97 100644
--- a/server/dto/parsemail/email.go
+++ b/server/dto/parsemail/email.go
@@ -12,6 +12,7 @@ import (
"github.com/emersion/go-message/mail"
log "github.com/sirupsen/logrus"
"github.com/spf13/cast"
+ "github.com/microcosm-cc/bluemonday"
"io"
"mime"
"net/textproto"
@@ -68,6 +69,84 @@ type Email struct {
Size int
}
+// Xss filter policy
+var (
+ strictPolicy *bluemonday.Policy
+ relaxedPolicy *bluemonday.Policy
+)
+
+func init() {
+ strictPolicy = bluemonday.StrictPolicy()
+
+ relaxedPolicy = bluemonday.NewPolicy()
+
+ relaxedPolicy.AllowElements("p", "br", "strong", "em", "u", "b", "i", "h1", "h2", "h3", "h4", "h5", "h6")
+ relaxedPolicy.AllowElements("div", "span", "center")
+ relaxedPolicy.AllowElements("ul", "ol", "li")
+ relaxedPolicy.AllowElements("blockquote", "cite")
+
+ relaxedPolicy.AllowElements("table", "tbody", "thead", "tr", "td", "th")
+ relaxedPolicy.AllowAttrs("width", "height", "border", "cellpadding", "cellspacing").OnElements("table")
+ relaxedPolicy.AllowAttrs("align", "valign", "colspan", "rowspan").OnElements("td", "th")
+ relaxedPolicy.AllowAttrs("align").OnElements("tr")
+
+ relaxedPolicy.AllowAttrs("style").Globally()
+ relaxedPolicy.AllowAttrs("class", "id").Globally()
+
+ relaxedPolicy.AllowAttrs("bgcolor", "color", "background").Globally()
+ relaxedPolicy.AllowAttrs("align").OnElements("p", "div", "h1", "h2", "h3", "h4", "h5", "h6")
+
+ relaxedPolicy.AllowElements("img")
+ relaxedPolicy.AllowAttrs("src", "alt", "width", "height", "style", "align").OnElements("img")
+
+ relaxedPolicy.AllowElements("a")
+ relaxedPolicy.AllowAttrs("href", "style").OnElements("a")
+ relaxedPolicy.RequireNoReferrerOnLinks(true)
+ relaxedPolicy.AddTargetBlankToFullyQualifiedLinks(true)
+ relaxedPolicy.RequireNoFollowOnLinks(true)
+
+ relaxedPolicy.AllowElements("font")
+ relaxedPolicy.AllowAttrs("size", "color", "face").OnElements("font")
+
+ relaxedPolicy.AllowElements("style")
+ relaxedPolicy.AllowAttrs("type").OnElements("style")
+
+ relaxedPolicy.AllowURLSchemes("http", "https", "mailto")
+
+ relaxedPolicy.SkipElementsContent("script", "object", "embed", "iframe", "frame", "frameset")
+}
+
+func sanitizeHTML(htmlContent string) string {
+ if htmlContent == "" {
+ return ""
+ }
+
+ sanitized := relaxedPolicy.Sanitize(htmlContent)
+
+ dataUrlRegex := regexp.MustCompile(`href\s*=\s*["']data:[^"']*["']`)
+ sanitized = dataUrlRegex.ReplaceAllString(sanitized, `rel="nofollow"`)
+
+ jsUrlRegex := regexp.MustCompile(`href\s*=\s*["']javascript:[^"']*["']`)
+ sanitized = jsUrlRegex.ReplaceAllString(sanitized, `rel="nofollow"`)
+
+ expressionRegex := regexp.MustCompile(`(?i)expression\s*\(.*?\)`)
+ sanitized = expressionRegex.ReplaceAllString(sanitized, "")
+
+ styleExpressionRegex := regexp.MustCompile(`(?i)style\s*=\s*["'][^"']*expression[^"']*["']`)
+ sanitized = styleExpressionRegex.ReplaceAllString(sanitized, "")
+
+ cssJsRegex := regexp.MustCompile(`(?i)javascript\s*:`)
+ sanitized = cssJsRegex.ReplaceAllString(sanitized, "")
+
+ return sanitized
+}
+
+
+// Sanitize Text
+func sanitizeText(text string) string {
+ return strictPolicy.Sanitize(text)
+}
+
func users2String(users []*User) string {
ret := ""
for _, user := range users {
@@ -162,8 +241,9 @@ func NewEmailFromReader(to []string, r io.Reader, size int) *Email {
if ret.Sender == nil {
ret.Sender = ret.From
}
-
- ret.Subject, _ = m.Header.Text("Subject")
+
+ subject, _ := m.Header.Text("Subject")
+ ret.Subject = strictPolicy.Sanitize(subject)
sendTime, err := time.Parse(time.RFC1123Z, m.Header.Get("Date"))
if err != nil {
@@ -173,6 +253,12 @@ func NewEmailFromReader(to []string, r io.Reader, size int) *Email {
m.Walk(func(path []int, entity *message.Entity, err error) error {
return formatContent(entity, ret)
})
+
+ if ret.From != nil {
+ ret.From.Name = strictPolicy.Sanitize(ret.From.Name)
+ ret.From.EmailAddress = strictPolicy.Sanitize(ret.From.EmailAddress)
+ }
+
return ret
}
@@ -188,9 +274,11 @@ func formatContent(entity *message.Entity, ret *Email) error {
case "multipart/alternative":
case "multipart/mixed":
case "text/plain":
- ret.Text, _ = io.ReadAll(entity.Body)
+ testContent, _ := io.ReadAll(entity.Body)
+ ret.Text = []byte(strictPolicy.Sanitize(string(testContent)))
case "text/html":
- ret.HTML, _ = io.ReadAll(entity.Body)
+ htmlContent, _ := io.ReadAll(entity.Body)
+ ret.HTML = []byte(relaxedPolicy.Sanitize(string(htmlContent)))
case "multipart/related":
entity.Walk(func(path []int, entity *message.Entity, err error) error {
if t, _, _ := entity.Header.ContentType(); t == "multipart/related" {
@@ -203,18 +291,19 @@ func formatContent(entity *message.Entity, ret *Email) error {
fileName := p["name"]
if fileName == "" {
contentDisposition := entity.Header.Get("Content-Disposition")
- r := regexp.MustCompile("filename=(.*)")
- matchs := r.FindStringSubmatch(contentDisposition)
- if len(matchs) == 2 {
- fileName = matchs[1]
+ filenameRegex := regexp.MustCompile(`filename\s*=\s*"?([^";]+)"?`)
+ matches := filenameRegex.FindStringSubmatch(contentDisposition)
+ if len(matches) >= 2 {
+ fileName = strings.TrimSpace(matches[1])
+ fileName = strings.Trim(fileName, `"`)
} else {
fileName = "no_name_file"
}
}
ret.Attachments = append(ret.Attachments, &Attachment{
- Filename: fileName,
- ContentType: contentType,
+ Filename: sanitizeText(fileName),
+ ContentType: sanitizeText(strings.TrimSpace(contentType)),
Content: c,
ContentID: strings.TrimPrefix(strings.TrimSuffix(entity.Header.Get("Content-Id"), ">"), "<"),
})
@@ -230,47 +319,62 @@ func BuilderUser(str string) *User {
var emailAddressRe = regexp.MustCompile(`<(.*@.*)>`)
func buildUser(str string) *User {
+ str = strings.TrimSpace(str)
if str == "" {
- return nil
+ return &User{}
}
- ret := &User{}
+ user := &User{}
+
+ addr, err := mail.ParseAddress(str)
+ if err == nil {
+ user.EmailAddress = strings.TrimSpace(addr.Address)
+
+ name := strings.TrimSpace(addr.Name)
+ if name != "" {
+ decoder := mime.WordDecoder{}
+ if decoded, err := decoder.Decode(name); err == nil {
+ name = decoded
+ }
+ user.Name = strictPolicy.Sanitize(name)
+ }
+ return user
+ }
matched := emailAddressRe.FindStringSubmatch(str)
-
if len(matched) == 2 {
- ret.EmailAddress = matched[1]
+ user.EmailAddress = strings.TrimSpace(matched[1])
+ namePart := strings.ReplaceAll(str, matched[0], "")
+ namePart = strings.Trim(strings.TrimSpace(namePart), "\"")
+
+ decoder := mime.WordDecoder{}
+ if decoded, err := decoder.Decode(strings.ReplaceAll(namePart, "\"", "")); err == nil {
+ user.Name = strictPolicy.Sanitize(strings.TrimSpace(decoded))
+ } else {
+ user.Name = strictPolicy.Sanitize(strings.TrimSpace(namePart))
+ }
} else {
- ret.EmailAddress = str
- return ret
+ user.EmailAddress = strictPolicy.Sanitize(str)
}
- str = strings.ReplaceAll(str, matched[0], "")
-
- str = strings.Trim(strings.TrimSpace(str), "\"")
-
- name, err := (&WordDecoder{}).Decode(strings.ReplaceAll(str, "\"", ""))
- if err == nil {
- ret.Name = strings.TrimSpace(name)
- } else {
- ret.Name = strings.TrimSpace(str)
- }
- return ret
+ return user
}
-func buildUsers(str []string) []*User {
+
+func buildUsers(strs []string) []*User {
var ret []*User
- for _, s1 := range str {
- if s1 == "" {
+ for _, line := range strs {
+ if strings.TrimSpace(line) == "" {
continue
}
- for _, s := range strings.Split(s1, ",") {
- s = strings.TrimSpace(s)
- ret = append(ret, buildUser(s))
+ parts := strings.Split(line, ",")
+ for _, part := range parts {
+ if u := buildUser(strings.TrimSpace(part)); u != nil {
+ ret = append(ret, u)
+ }
}
}
-
return ret
}
diff --git a/server/dto/parsemail/email_xss_test.go b/server/dto/parsemail/email_xss_test.go
new file mode 100644
index 00000000..1f8f878c
--- /dev/null
+++ b/server/dto/parsemail/email_xss_test.go
@@ -0,0 +1,440 @@
+package parsemail
+
+import (
+ "bytes"
+ "io"
+ "strings"
+ "testing"
+
+ "github.com/emersion/go-message"
+)
+
+// XSS Test Cases
+func TestSanitizeHTML(t *testing.T) {
+ testCases := []struct {
+ name string
+ input string
+ expected string
+ }{
+ {
+ name: "Simple script tag",
+ input: ``,
+ expected: ``,
+ },
+ {
+ name: "Image with onerror - should remove dangerous attributes",
+ input: ``,
+ expected: ``, // bluemonday UGCPolicy removes img tags with dangerous attributes
+ },
+ {
+ name: "SVG with onload",
+ input: ``,
+ expected: ``,
+ },
+ {
+ name: "Iframe with javascript",
+ input: ``,
+ expected: ``,
+ },
+ {
+ name: "Safe HTML content",
+ input: `
Hello World!
`, + expected: `Hello World!
`, + }, + { + name: "Link with javascript protocol - removes dangerous href", + input: `Click me`, + expected: `Click me`, // bluemonday removes entire link when href is dangerous + }, + { + name: "Style tag with javascript", + input: ``, + expected: ``, + }, + { + name: "Object tag", + input: ``, + expected: ``, + }, + { + name: "Form tag", + input: ``, + expected: ``, + }, + { + name: "Mixed content with XSS - keeps safe div tags", + input: `Safe content
Safe content
Hello World!
`, + expected: `Hello World!`, + }, + { + name: "Plain text", + input: `Hello World! This is plain text.`, + expected: `Hello World! This is plain text.`, + }, + { + name: "Text with special characters", + input: `Hello & goodbye < > " '`, + expected: `Hello & goodbye < > " '`, + }, + { + name: "Image tag in text", + input: `Safe content
`) + htmlPart.Close() + + var textHeader message.Header + textHeader.SetContentType("text/plain", nil) + textPart, err := w.CreatePart(textHeader) + if err != nil { + t.Fatal(err) + } + io.WriteString(textPart, `Hello World`) + textPart.Close() + + w.Close() + + email := NewEmailFromReader([]string{"to@example.com"}, &b, b.Len()) + + if email.From.EmailAddress != "from@example.com" { + t.Errorf("Expected From.EmailAddress to be 'from@example.com', got: %q", email.From.EmailAddress) + } + + if email.From.Name != "Safe Name" { + t.Errorf("Expected From.Name to be 'Safe Name', got: %q", email.From.Name) + } + + expectedSubject := "Test Subject" + if email.Subject != expectedSubject { + t.Errorf("Expected Subject to be %q, got: %q", expectedSubject, email.Subject) + } + + htmlContent := string(email.HTML) + if strings.Contains(htmlContent, "malicious.txt` + sanitizedFilename := sanitizeText(dangerousFilename) + if strings.Contains(sanitizedFilename, "` + sanitizedContentType := sanitizeText(dangerousContentType) + if strings.Contains(sanitizedContentType, "`, + testType: "html", + }, + { + name: "Base64 encoded script", + input: `