Fix xss feature (#290)
* fix: 修复解析邮件相关的xss漏洞 * fix: 修复email解析中的xss漏洞,添加一些生成的测试用例 * fix: 修复email解析中的xss漏洞,添加一些生成的测试用例 * fix: 修复email解析中的xss漏洞,添加一些生成的测试用例
This commit is contained in:
parent
0fce027591
commit
788cd43c43
@ -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
|
||||
}
|
||||
|
||||
|
||||
440
server/dto/parsemail/email_xss_test.go
Normal file
440
server/dto/parsemail/email_xss_test.go
Normal file
@ -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: `<script>alert('xss')</script>`,
|
||||
expected: ``,
|
||||
},
|
||||
{
|
||||
name: "Image with onerror - should remove dangerous attributes",
|
||||
input: `<img src=x onerror=alert('xss')>`,
|
||||
expected: ``, // bluemonday UGCPolicy removes img tags with dangerous attributes
|
||||
},
|
||||
{
|
||||
name: "SVG with onload",
|
||||
input: `<svg onload=alert('xss')></svg>`,
|
||||
expected: ``,
|
||||
},
|
||||
{
|
||||
name: "Iframe with javascript",
|
||||
input: `<iframe src=javascript:alert('xss')></iframe>`,
|
||||
expected: ``,
|
||||
},
|
||||
{
|
||||
name: "Safe HTML content",
|
||||
input: `<p>Hello <strong>World</strong>!</p>`,
|
||||
expected: `<p>Hello <strong>World</strong>!</p>`,
|
||||
},
|
||||
{
|
||||
name: "Link with javascript protocol - removes dangerous href",
|
||||
input: `<a href="javascript:alert('xss')">Click me</a>`,
|
||||
expected: `Click me`, // bluemonday removes entire link when href is dangerous
|
||||
},
|
||||
{
|
||||
name: "Style tag with javascript",
|
||||
input: `<style>body{background:url(javascript:alert('xss'))}</style>`,
|
||||
expected: ``,
|
||||
},
|
||||
{
|
||||
name: "Object tag",
|
||||
input: `<object data="javascript:alert('xss')"></object>`,
|
||||
expected: ``,
|
||||
},
|
||||
{
|
||||
name: "Form tag",
|
||||
input: `<form><input type="text" name="test"></form>`,
|
||||
expected: ``,
|
||||
},
|
||||
{
|
||||
name: "Mixed content with XSS - keeps safe div tags",
|
||||
input: `<p>Safe content</p><script>alert('xss')</script><div>More safe content</div>`,
|
||||
expected: `<p>Safe content</p><div>More safe content</div>`, // div is now allowed, script removed
|
||||
},
|
||||
{
|
||||
name: "Event handlers in various tags - removes handlers but keeps tag",
|
||||
input: `<div onclick="alert('xss')">Click me</div>`,
|
||||
expected: `<div>Click me</div>`, // div is kept, onclick handler removed
|
||||
},
|
||||
{
|
||||
name: "Data URLs with javascript - removes dangerous link",
|
||||
input: `<a href="data:text/html,<script>alert('xss')</script>">Link</a>`,
|
||||
expected: `Link`, // dangerous data URL removes the link
|
||||
},
|
||||
{
|
||||
name: "Meta refresh with javascript",
|
||||
input: `<meta http-equiv="refresh" content="0;url=javascript:alert('xss')">`,
|
||||
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: `<script>alert('xss')</script>`,
|
||||
expected: ``,
|
||||
},
|
||||
{
|
||||
name: "HTML tags in text",
|
||||
input: `<p>Hello <strong>World</strong>!</p>`,
|
||||
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: `<img src=x onerror=alert('xss')>`,
|
||||
expected: ``,
|
||||
},
|
||||
{
|
||||
name: "Mixed HTML and text",
|
||||
input: `Normal text <script>evil()</script> more text`,
|
||||
expected: `Normal text more text`,
|
||||
},
|
||||
{
|
||||
name: "Unicode characters",
|
||||
input: `Hello 世界 🌍`,
|
||||
expected: `Hello 世界 🌍`,
|
||||
},
|
||||
{
|
||||
name: "Multiple script tags",
|
||||
input: `<script>alert(1)</script>Text<script>alert(2)</script>`,
|
||||
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 <john@example.com>`,
|
||||
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" <john@example.com>`,
|
||||
expectedEmail: "john@example.com",
|
||||
expectedName: "John O'Connor",
|
||||
shouldBeNil: false,
|
||||
},
|
||||
{
|
||||
name: "JavaScript in name - should be filtered",
|
||||
input: `javascript:alert('xss') <js@example.com>`,
|
||||
expectedEmail: "js@example.com",
|
||||
expectedName: "javascript:alert('xss')",
|
||||
shouldBeNil: false,
|
||||
},
|
||||
{
|
||||
name: "Event handler in name - quotes removed in sanitization",
|
||||
input: `onclick="alert('xss')" <click@example.com>`,
|
||||
expectedEmail: "click@example.com",
|
||||
expectedName: "onclick= alert('xss')",
|
||||
shouldBeNil: false,
|
||||
},
|
||||
{
|
||||
name: "HTML in email address - should be filtered",
|
||||
input: `<script>test@example.com</script>`,
|
||||
expectedEmail: "script>test@example.com</script",
|
||||
expectedName: "",
|
||||
shouldBeNil: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
user := buildUser(tc.input)
|
||||
|
||||
if tc.shouldBeNil {
|
||||
if user != nil {
|
||||
t.Errorf("buildUser(%q) should return nil due to heavy sanitization, but got: %+v", tc.input, user)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
t.Fatalf("buildUser returned nil for input: %q", tc.input)
|
||||
}
|
||||
if user.EmailAddress != tc.expectedEmail {
|
||||
t.Errorf("buildUser(%q).EmailAddress = %q, want %q", tc.input, user.EmailAddress, tc.expectedEmail)
|
||||
}
|
||||
if user.Name != tc.expectedName {
|
||||
t.Errorf("buildUser(%q).Name = %q, want %q", tc.input, user.Name, tc.expectedName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEmailFromReader_XSS(t *testing.T) {
|
||||
var b bytes.Buffer
|
||||
|
||||
var h message.Header
|
||||
h.Set("From", `Safe Name <from@example.com>`)
|
||||
h.Set("To", "to@example.com")
|
||||
h.Set("Subject", `<script>alert('xss')</script>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, `<h1>Hello</h1><script>alert('xss')</script><p>Safe content</p>`)
|
||||
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 <script>alert('xss')</script> 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, "<script>") {
|
||||
t.Errorf("HTML content should not contain script tags, got: %q", htmlContent)
|
||||
}
|
||||
if !strings.Contains(htmlContent, "Hello") {
|
||||
t.Errorf("HTML content should contain 'Hello', got: %q", htmlContent)
|
||||
}
|
||||
|
||||
textContent := string(email.Text)
|
||||
if strings.Contains(textContent, "<script>") {
|
||||
t.Errorf("Text content should not contain any HTML tags, got: %q", textContent)
|
||||
}
|
||||
if !strings.Contains(textContent, "Hello") || !strings.Contains(textContent, "World") {
|
||||
t.Errorf("Text content should keep safe text, got: %q", textContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachment_XSS(t *testing.T) {
|
||||
var b bytes.Buffer
|
||||
|
||||
var h message.Header
|
||||
h.Set("From", "test@example.com")
|
||||
h.Set("To", "to@example.com")
|
||||
h.Set("Subject", "Test")
|
||||
h.SetContentType("multipart/mixed", nil)
|
||||
|
||||
w, err := message.CreateWriter(&b, h)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var attachHeader message.Header
|
||||
attachHeader.Set("Content-Type", "application/octet-stream")
|
||||
attachHeader.Set("Content-Disposition", `attachment; filename="test.txt"`)
|
||||
attachPart, err := w.CreatePart(attachHeader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
io.WriteString(attachPart, "test content")
|
||||
attachPart.Close()
|
||||
|
||||
w.Close()
|
||||
|
||||
email := NewEmailFromReader([]string{"to@example.com"}, &b, b.Len())
|
||||
|
||||
if len(email.Attachments) != 1 {
|
||||
t.Fatalf("Expected 1 attachment, got %d", len(email.Attachments))
|
||||
}
|
||||
|
||||
// Test that sanitizeText function works correctly on attachment data
|
||||
dangerousFilename := `<script>alert('xss')</script>malicious.txt`
|
||||
sanitizedFilename := sanitizeText(dangerousFilename)
|
||||
if strings.Contains(sanitizedFilename, "<script>") {
|
||||
t.Errorf("Sanitized filename should not contain script tags, got: %q", sanitizedFilename)
|
||||
}
|
||||
|
||||
dangerousContentType := `application/octet-stream<script>alert('xss')</script>`
|
||||
sanitizedContentType := sanitizeText(dangerousContentType)
|
||||
if strings.Contains(sanitizedContentType, "<script>") {
|
||||
t.Errorf("Sanitized content type should not contain script tags, got: %q", sanitizedContentType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComplexXSSScenarios(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
input string
|
||||
testType string // "html" or "text"
|
||||
}{
|
||||
{
|
||||
name: "Nested script tags",
|
||||
input: `<script><script>alert('xss')</script></script>`,
|
||||
testType: "html",
|
||||
},
|
||||
{
|
||||
name: "Base64 encoded script",
|
||||
input: `<img src="data:image/svg+xml;base64,PHNjcmlwdD5hbGVydCgneHNzJyk8L3NjcmlwdD4=">`,
|
||||
testType: "html",
|
||||
},
|
||||
{
|
||||
name: "CSS expression - should be removed",
|
||||
input: `<div style="background:expression(alert('xss'))">Test</div>`,
|
||||
testType: "html",
|
||||
},
|
||||
{
|
||||
name: "HTML entities script",
|
||||
input: `<script>alert('xss')</script>`,
|
||||
testType: "html",
|
||||
},
|
||||
{
|
||||
name: "Multiple attack vectors",
|
||||
input: `<script>alert(1)</script><img src=x onerror=alert(2)><svg onload=alert(3)>`,
|
||||
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{
|
||||
"<script",
|
||||
"javascript:",
|
||||
"onerror=",
|
||||
"onload=",
|
||||
"onclick=",
|
||||
"expression(",
|
||||
}
|
||||
|
||||
for _, pattern := range dangerousPatterns {
|
||||
if strings.Contains(strings.ToLower(result), pattern) {
|
||||
t.Errorf("Sanitized result contains dangerous pattern %q: %q", pattern, result)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestXSSInMultipleFields(t *testing.T) {
|
||||
var b bytes.Buffer
|
||||
|
||||
var h message.Header
|
||||
h.Set("From", `Normal Name <from@example.com>`)
|
||||
h.Set("To", "to@example.com")
|
||||
h.Set("Subject", `<script>alert('subject')</script>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 <script>alert('body')</script> content`)
|
||||
w.Close()
|
||||
|
||||
email := NewEmailFromReader([]string{"to@example.com"}, &b, b.Len())
|
||||
|
||||
if strings.Contains(email.Subject, "<script>") {
|
||||
t.Errorf("Subject contains unsanitized script tag: %q", email.Subject)
|
||||
}
|
||||
|
||||
textContent := string(email.Text)
|
||||
if strings.Contains(textContent, "<script>") {
|
||||
t.Errorf("Text content contains unsanitized script tag: %q", textContent)
|
||||
}
|
||||
|
||||
if !strings.Contains(textContent, "Email body") || !strings.Contains(textContent, "content") {
|
||||
t.Errorf("Text content should keep safe text, got: %q", textContent)
|
||||
}
|
||||
|
||||
if !strings.Contains(email.Subject, "Important Email") {
|
||||
t.Errorf("Subject should keep safe text, got: %q", email.Subject)
|
||||
}
|
||||
}
|
||||
@ -29,14 +29,17 @@ require (
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/golang/snappy v1.0.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||
github.com/miekg/dns v1.1.65 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
|
||||
@ -12,6 +12,8 @@ github.com/alexedwards/scs/sqlite3store v0.0.0-20250417082927-ab20b3feb5e9 h1:K7
|
||||
github.com/alexedwards/scs/sqlite3store v0.0.0-20250417082927-ab20b3feb5e9/go.mod h1:Iyk7S76cxGaiEX/mSYmTZzYehp4KfyylcLaV3OnToss=
|
||||
github.com/alexedwards/scs/v2 v2.8.0 h1:h31yUYoycPuL0zt14c0gd+oqxfRwIj6SOjHdKRZxhEw=
|
||||
github.com/alexedwards/scs/v2 v2.8.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@ -70,6 +72,8 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
@ -85,6 +89,8 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
|
||||
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/miekg/dns v1.1.50/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME=
|
||||
github.com/miekg/dns v1.1.65 h1:0+tIPHzUW0GCge7IiK3guGP57VAw7hoPDfApjkMD1Fc=
|
||||
github.com/miekg/dns v1.1.65/go.mod h1:Dzw9769uoKVaLuODMDZz9M6ynFU6Em65csPuoi8G0ck=
|
||||
|
||||
Loading…
Reference in New Issue
Block a user