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

More safe content
`, + expected: `

Safe content

More safe content
`, // div is now allowed, script removed + }, + { + name: "Event handlers in various tags - removes handlers but keeps tag", + input: `
Click me
`, + expected: `
Click me
`, // div is kept, onclick handler removed + }, + { + name: "Data URLs with javascript - removes dangerous link", + input: `Link`, + expected: `Link`, // dangerous data URL removes the link + }, + { + name: "Meta refresh with javascript", + input: ``, + expected: ``, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := sanitizeHTML(tc.input) + if result != tc.expected { + t.Errorf("sanitizeHTML(%q) = %q, want %q", tc.input, result, tc.expected) + } + }) + } +} + +func TestSanitizeText(t *testing.T) { + testCases := []struct { + name string + input string + expected string + }{ + { + name: "Script tag in text", + input: ``, + expected: ``, + }, + { + name: "HTML tags in text", + input: `

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: ``, + expected: ``, + }, + { + name: "Mixed HTML and text", + input: `Normal text more text`, + expected: `Normal text more text`, + }, + { + name: "Unicode characters", + input: `Hello 世界 🌍`, + expected: `Hello 世界 🌍`, + }, + { + name: "Multiple script tags", + input: `Text`, + expected: `Text`, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := sanitizeText(tc.input) + if result != tc.expected { + t.Errorf("sanitizeText(%q) = %q, want %q", tc.input, result, tc.expected) + } + }) + } +} + +func TestBuildUser_XSS(t *testing.T) { + testCases := []struct { + name string + input string + expectedEmail string + expectedName string + shouldBeNil bool + }{ + { + name: "Normal user", + input: `John Doe `, + expectedEmail: "john@example.com", + expectedName: "John Doe", + shouldBeNil: false, + }, + { + name: "Simple email only", + input: `test@example.com`, + expectedEmail: "test@example.com", + expectedName: "", + shouldBeNil: false, + }, + { + name: "User with quotes", + input: `"John O'Connor" `, + expectedEmail: "john@example.com", + expectedName: "John O'Connor", + shouldBeNil: false, + }, + { + name: "JavaScript in name - should be filtered", + input: `javascript:alert('xss') `, + expectedEmail: "js@example.com", + expectedName: "javascript:alert('xss')", + shouldBeNil: false, + }, + { + name: "Event handler in name - quotes removed in sanitization", + input: `onclick="alert('xss')" `, + expectedEmail: "click@example.com", + expectedName: "onclick= alert('xss')", + shouldBeNil: false, + }, + { + name: "HTML in email address - should be filtered", + input: ``, + expectedEmail: "script>test@example.com`) + h.Set("To", "to@example.com") + h.Set("Subject", `Test Subject`) + h.Set("Date", "Mon, 02 Jan 2006 15:04:05 -0700") + h.SetContentType("multipart/alternative", nil) + + w, err := message.CreateWriter(&b, h) + if err != nil { + t.Fatal(err) + } + + var htmlHeader message.Header + htmlHeader.SetContentType("text/html", nil) + htmlPart, err := w.CreatePart(htmlHeader) + if err != nil { + t.Fatal(err) + } + io.WriteString(htmlPart, `

Hello

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: ``, + testType: "html", + }, + { + name: "CSS expression - should be removed", + input: `
Test
`, + testType: "html", + }, + { + name: "HTML entities script", + input: `<script>alert('xss')</script>`, + testType: "html", + }, + { + name: "Multiple attack vectors", + input: ``, + testType: "html", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var result string + if tc.testType == "html" { + result = sanitizeHTML(tc.input) + } else { + result = sanitizeText(tc.input) + } + + // 检查结果中不应包含危险的内容 + dangerousPatterns := []string{ + "`) + h.Set("To", "to@example.com") + h.Set("Subject", `Important Email`) + h.Set("Date", "Mon, 02 Jan 2006 15:04:05 -0700") + h.SetContentType("text/plain", nil) + + w, err := message.CreateWriter(&b, h) + if err != nil { + t.Fatal(err) + } + + io.WriteString(w, `Email body with content`) + w.Close() + + email := NewEmailFromReader([]string{"to@example.com"}, &b, b.Len()) + + if strings.Contains(email.Subject, "