From 778e279b4faef721eb0b022d34fa81bd483cc78c Mon Sep 17 00:00:00 2001 From: Jinnrry <19919556+Jinnrry@users.noreply.github.com> Date: Sat, 30 Aug 2025 20:25:11 +0800 Subject: [PATCH] feature/v2.8.6 (#304) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit imap协议完善 smtp信息泄露修复 --- docs/debug.md | 3 + server/db/init.go | 6 +- server/dto/parsemail/email.go | 103 +++++++++----- server/dto/parsemail/email_test.go | 11 ++ server/go.mod | 2 +- server/listen/imap_server/session_fetch.go | 141 ++++++++++++++++++-- server/listen/imap_server/session_search.go | 14 +- server/listen/smtp_server/action.go | 2 +- server/listen/smtp_server/read_content.go | 4 +- 9 files changed, 229 insertions(+), 57 deletions(-) create mode 100644 docs/debug.md diff --git a/docs/debug.md b/docs/debug.md new file mode 100644 index 0000000..b03dadd --- /dev/null +++ b/docs/debug.md @@ -0,0 +1,3 @@ +测试imap协议返回 + +`openssl s_client -crlf -connect imap.xxxx.com:993` \ No newline at end of file diff --git a/server/db/init.go b/server/db/init.go index b7c92e0..fbe014c 100644 --- a/server/db/init.go +++ b/server/db/init.go @@ -65,9 +65,9 @@ func Init(version string) error { } } - if config.Instance.LogLevel == "debug" { - Instance.ShowSQL(true) - } + //if config.Instance.LogLevel == "debug" { + // Instance.ShowSQL(true) + //} return nil } diff --git a/server/dto/parsemail/email.go b/server/dto/parsemail/email.go index 8a79db9..b10361b 100644 --- a/server/dto/parsemail/email.go +++ b/server/dto/parsemail/email.go @@ -2,6 +2,7 @@ package parsemail import ( "bytes" + "encoding/base64" "encoding/json" "fmt" "github.com/Jinnrry/pmail/config" @@ -10,9 +11,9 @@ import ( "github.com/emersion/go-message" _ "github.com/emersion/go-message/charset" "github.com/emersion/go-message/mail" + "github.com/microcosm-cc/bluemonday" log "github.com/sirupsen/logrus" "github.com/spf13/cast" - "github.com/microcosm-cc/bluemonday" "io" "mime" "net/textproto" @@ -71,48 +72,48 @@ type Email struct { // Xss filter policy var ( - strictPolicy *bluemonday.Policy + 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") } @@ -120,31 +121,30 @@ 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) + return strictPolicy.Sanitize(text) } func users2String(users []*User) string { @@ -241,7 +241,7 @@ func NewEmailFromReader(to []string, r io.Reader, size int) *Email { if ret.Sender == nil { ret.Sender = ret.From } - + subject, _ := m.Header.Text("Subject") ret.Subject = strictPolicy.Sanitize(subject) @@ -253,12 +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 } @@ -360,7 +360,6 @@ func buildUser(str string) *User { return user } - func buildUsers(strs []string) []*User { var ret []*User for _, line := range strs { @@ -461,6 +460,33 @@ func (e *Email) ForwardBuildBytes(ctx *context.Context, sender *models.User) []b return instance.Sign(b.String()) } +func (e *Email) BuildPart(ctx *context.Context, loc []int) []byte { + if len(loc) < 2 { + return nil + } + if loc[0] == 1 && loc[1] == 2 { + + encoded := base64.StdEncoding.EncodeToString(e.HTML) + encoded += "\r\n" + + return []byte(encoded) + } + if loc[0] == 1 && loc[1] == 1 { + if len(e.Text) == 0 { + encoded := base64.StdEncoding.EncodeToString(e.HTML) + encoded += "\r\n" + + return []byte(encoded) + } + encoded := base64.StdEncoding.EncodeToString(e.Text) + encoded += "\r\n" + + return []byte(encoded) + } + + return nil +} + func (e *Email) BuildBytes(ctx *context.Context, dkim bool) []byte { var b bytes.Buffer @@ -513,24 +539,27 @@ func (e *Email) BuildBytes(ctx *context.Context, dkim bool) []byte { if err != nil { log.WithContext(ctx).Fatal(err) } - var th mail.InlineHeader - th.Header.Set("Content-Transfer-Encoding", "base64") - th.SetContentType("text/plain", map[string]string{ - "charset": "UTF-8", - }) - w, err := tw.CreatePart(th) - if err != nil { - log.Fatal(err) + + if len(e.Text) > 0 { + var th mail.InlineHeader + th.Header.Set("Content-Transfer-Encoding", "base64") + th.SetContentType("text/plain", map[string]string{ + "charset": "UTF-8", + }) + w, err := tw.CreatePart(th) + if err != nil { + log.Fatal(err) + } + io.WriteString(w, string(e.Text)) + w.Close() } - io.WriteString(w, string(e.Text)) - w.Close() var html mail.InlineHeader html.SetContentType("text/html", map[string]string{ "charset": "UTF-8", }) html.Header.Set("Content-Transfer-Encoding", "base64") - w, err = tw.CreatePart(html) + w, err := tw.CreatePart(html) if err != nil { log.Fatal(err) } diff --git a/server/dto/parsemail/email_test.go b/server/dto/parsemail/email_test.go index 96aaaf5..9df92a8 100644 --- a/server/dto/parsemail/email_test.go +++ b/server/dto/parsemail/email_test.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/emersion/go-message" "io" + "testing" ) @@ -84,3 +85,13 @@ func TestEmail_builder(t *testing.T) { rest := e.BuildBytes(nil, false) fmt.Println(string(rest)) } + +func TestEmail_BuildPart(t *testing.T) { + e := Email{ + Text: []byte("text"), + HTML: []byte("html"), + } + res := e.BuildPart(nil, []int{1, 2}) + fmt.Println(string(res)) + +} diff --git a/server/go.mod b/server/go.mod index e1b9bb4..78f240a 100644 --- a/server/go.mod +++ b/server/go.mod @@ -17,6 +17,7 @@ require ( github.com/go-acme/lego/v4 v4.23.1 github.com/go-sql-driver/mysql v1.9.2 github.com/lib/pq v1.10.9 + github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/spf v0.9.5 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cast v1.7.1 @@ -39,7 +40,6 @@ require ( 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 diff --git a/server/listen/imap_server/session_fetch.go b/server/listen/imap_server/session_fetch.go index a3ebc32..0dce1fc 100644 --- a/server/listen/imap_server/session_fetch.go +++ b/server/listen/imap_server/session_fetch.go @@ -42,14 +42,131 @@ func (s *serverSession) Fetch(w *imapserver.FetchWriter, numSet imap.NumSet, opt return nil } +// 纯文本 text/plain +func bsTextPlain(size uint32, numLines int64) *imap.BodyStructureSinglePart { + return &imap.BodyStructureSinglePart{ + Type: "text", + Subtype: "plain", + Params: map[string]string{"charset": "utf-8"}, + Encoding: "base64", + Size: size, // 按字节数 + Text: &imap.BodyStructureText{NumLines: numLines}, + Extended: &imap.BodyStructureSinglePartExt{}, + } +} + +// HTML text/html +func bsTextHTML(size uint32, numLines int64) *imap.BodyStructureSinglePart { + return &imap.BodyStructureSinglePart{ + Type: "text", + Subtype: "html", + Params: map[string]string{"charset": "utf-8"}, + Encoding: "base64", + Size: size, + Text: &imap.BodyStructureText{NumLines: numLines}, + Extended: &imap.BodyStructureSinglePartExt{}, + } +} + +// 通用附件(传入 MIME,如 "application/pdf") +func bsAttachment(filename, mime string, size uint32, encoding string) *imap.BodyStructureSinglePart { + mt, st := "application", "octet-stream" + if slash := strings.IndexByte(mime, '/'); slash > 0 { + mt, st = mime[:slash], mime[slash+1:] + } + return &imap.BodyStructureSinglePart{ + Type: mt, + Subtype: st, + Params: map[string]string{"name": filename}, // 备用名 + ID: "", // 可填 Content-ID + Encoding: encoding, // 常见 "base64" + Size: size, + Extended: &imap.BodyStructureSinglePartExt{ + Disposition: &imap.BodyStructureDisposition{ + Value: "attachment", + Params: map[string]string{"filename": filename}, // 客户端优先用这里 + }, + }, + } +} + +// multipart/alternative:text + html +func bsAlternative(text, html *imap.BodyStructureSinglePart) *imap.BodyStructureMultiPart { + if text == nil && html == nil { + return &imap.BodyStructureMultiPart{ + Subtype: "alternative", + Children: []imap.BodyStructure{}, + Extended: &imap.BodyStructureMultiPartExt{}, + } + } + if text == nil { + return &imap.BodyStructureMultiPart{ + Subtype: "alternative", + Children: []imap.BodyStructure{html}, + Extended: &imap.BodyStructureMultiPartExt{}, // 可选:Params/Disposition/Language/Location + } + } + if html == nil { + return &imap.BodyStructureMultiPart{ + Subtype: "alternative", + Children: []imap.BodyStructure{text}, + Extended: &imap.BodyStructureMultiPartExt{}, // 可选:Params/Disposition/Language/Location + } + } + + return &imap.BodyStructureMultiPart{ + Subtype: "alternative", + Children: []imap.BodyStructure{text, html}, + Extended: &imap.BodyStructureMultiPartExt{}, // 可选:Params/Disposition/Language/Location + } +} + +// multipart/mixed:{ alternative(text+html), attachments... } +func bsMixedWithAttachments(alt *imap.BodyStructureMultiPart, extend bool, atts ...imap.BodyStructure) *imap.BodyStructureMultiPart { + children := []imap.BodyStructure{alt} + children = append(children, atts...) + var ext *imap.BodyStructureMultiPartExt + if extend { + ext = &imap.BodyStructureMultiPartExt{} + } + return &imap.BodyStructureMultiPart{ + Subtype: "mixed", + Children: children, + Extended: ext, + } +} + func write(ctx *context.Context, w *imapserver.FetchWriter, emailList []*response.EmailResponseData, options *imap.FetchOptions) { for _, email := range emailList { writer := w.CreateMessage(cast.ToUint32(email.SerialNumber)) + + traEmail := parsemail.NewEmailFromModel(email.Email) + if options.UID { writer.WriteUID(imap.UID(email.UeId)) } + if options.BodyStructure != nil { + var html, text *imap.BodyStructureSinglePart + if len(traEmail.HTML) > 0 { + html = bsTextHTML(uint32(len(traEmail.HTML)), int64(bytes.Count(traEmail.HTML, []byte("\n"))+1)) + } + + if len(traEmail.Text) > 0 { + text = bsTextPlain(uint32(len(traEmail.Text)), int64(bytes.Count(traEmail.Text, []byte("\n"))+1)) + } + + alt := bsAlternative(text, html) + + var attrs []imap.BodyStructure + for _, attachment := range traEmail.Attachments { + attrs = append(attrs, bsAttachment(attachment.Filename, attachment.ContentType, uint32(len(attachment.Content)), "base64")) + } + bs := bsMixedWithAttachments(alt, options.BodyStructure.Extended, attrs...) // 最终的 BodyStructure(接口值) + + writer.WriteBodyStructure(bs) + } if options.RFC822Size { - emailContent := parsemail.NewEmailFromModel(email.Email).BuildBytes(ctx, false) + emailContent := traEmail.BuildBytes(ctx, false) writer.WriteRFC822Size(cast.ToInt64(len(emailContent))) } if options.Flags { @@ -66,16 +183,22 @@ func write(ctx *context.Context, w *imapserver.FetchWriter, emailList []*respons if !section.Peek { detail.MakeRead(ctx, email.Id, true) } - emailContent := parsemail.NewEmailFromModel(email.Email).BuildBytes(ctx, false) + emailContent := traEmail.BuildBytes(ctx, false) if section.Specifier == imap.PartSpecifierNone || section.Specifier == imap.PartSpecifierText { - bodyWriter := writer.WriteBodySection(section, cast.ToInt64(len(emailContent))) - bodyWriter.Write(emailContent) - bodyWriter.Close() + if len(section.Part) == 2 { + // 取text部分 + bodyWriter := writer.WriteBodySection(section, cast.ToInt64(len(emailContent))) + bodyWriter.Write(traEmail.BuildPart(ctx, section.Part)) + bodyWriter.Close() + } else { + bodyWriter := writer.WriteBodySection(section, cast.ToInt64(len(emailContent))) + bodyWriter.Write(emailContent) + bodyWriter.Close() + } } if section.Specifier == imap.PartSpecifierHeader { var b bytes.Buffer - parseEmail := parsemail.NewEmailFromModel(email.Email) fields := section.HeaderFields if fields == nil || len(fields) == 0 { @@ -97,10 +220,10 @@ func write(ctx *context.Context, w *imapserver.FetchWriter, emailList []*respons fmt.Fprintf(&b, "From: %s\r\n", email.FromAddress) } case "to": - fmt.Fprintf(&b, "To: %s\r\n", parseEmail.BuildTo2String()) + fmt.Fprintf(&b, "To: %s\r\n", traEmail.BuildTo2String()) case "cc": - if len(parseEmail.Cc) > 0 { - fmt.Fprintf(&b, "Cc: %s\r\n", parseEmail.BuildCc2String()) + if len(traEmail.Cc) > 0 { + fmt.Fprintf(&b, "Cc: %s\r\n", traEmail.BuildCc2String()) } case "message-id": fmt.Fprintf(&b, "Message-ID: %s\r\n", fmt.Sprintf("%d@%s", email.Id, config.Instance.Domain)) diff --git a/server/listen/imap_server/session_search.go b/server/listen/imap_server/session_search.go index 55bf381..865840b 100644 --- a/server/listen/imap_server/session_search.go +++ b/server/listen/imap_server/session_search.go @@ -11,12 +11,18 @@ import ( func (s *serverSession) Search(kind imapserver.NumKind, criteria *imap.SearchCriteria, options *imap.SearchOptions) (*imap.SearchData, error) { retList := []*response.UserEmailUIDData{} - for _, uidSet := range criteria.UID { - for _, uid := range uidSet { - res := list.GetUEListByUID(s.ctx, s.currentMailbox, cast.ToInt(uint32(uid.Start)), cast.ToInt(uint32(uid.Stop)), nil) - retList = append(retList, res...) + if len(criteria.UID) > 0 { + for _, uidSet := range criteria.UID { + for _, uid := range uidSet { + res := list.GetUEListByUID(s.ctx, s.currentMailbox, cast.ToInt(uint32(uid.Start)), cast.ToInt(uint32(uid.Stop)), nil) + retList = append(retList, res...) + } } + } else { + res := list.GetUEListByUID(s.ctx, s.currentMailbox, 0, 0, nil) + retList = append(retList, res...) } + ret := &imap.SearchData{} if kind == imapserver.NumKindSeq { diff --git a/server/listen/smtp_server/action.go b/server/listen/smtp_server/action.go index f85bc07..5db488e 100644 --- a/server/listen/smtp_server/action.go +++ b/server/listen/smtp_server/action.go @@ -2,10 +2,10 @@ package smtp_server import ( "database/sql" + "errors" "github.com/Jinnrry/pmail/db" "github.com/Jinnrry/pmail/models" "github.com/Jinnrry/pmail/utils/context" - "github.com/Jinnrry/pmail/utils/errors" "github.com/Jinnrry/pmail/utils/id" "github.com/Jinnrry/pmail/utils/password" "github.com/emersion/go-sasl" diff --git a/server/listen/smtp_server/read_content.go b/server/listen/smtp_server/read_content.go index 92159ec..c74d3eb 100644 --- a/server/listen/smtp_server/read_content.go +++ b/server/listen/smtp_server/read_content.go @@ -4,6 +4,7 @@ import ( "bytes" "database/sql" "encoding/json" + oerrors "errors" "github.com/Jinnrry/pmail/config" "github.com/Jinnrry/pmail/db" "github.com/Jinnrry/pmail/dto/parsemail" @@ -15,7 +16,6 @@ import ( "github.com/Jinnrry/pmail/utils/array" "github.com/Jinnrry/pmail/utils/async" "github.com/Jinnrry/pmail/utils/context" - "github.com/Jinnrry/pmail/utils/errors" "github.com/Jinnrry/pmail/utils/send" "github.com/mileusna/spf" log "github.com/sirupsen/logrus" @@ -69,7 +69,7 @@ func (s *Session) Data(r io.Reader) error { if s.Ctx.UserID > 0 { account, _ := email.From.GetDomainAccount() if account != ctx.UserAccount && !ctx.IsAdmin { - return errors.New("No Auth") + return oerrors.New("No Auth") } log.WithContext(ctx).Debugf("开始执行插件SendBefore!")