fix(security): 修复 P1 高危项(OAuth2 state / 伪造客户端IP / CRLF 邮件头注入)
- OAuth2: state 改为 crypto/rand 随机值并写入独立短期 cookie (主会话为 SameSite=Strict,跨站回调不携带,不能放主会话); 回调用 ConstantTimeCompare 校验 state,缺失/不匹配返回 403, 校验后立即清除保证一次性使用。原硬编码 mailgo_oauth2_state 可被利用做授权码注入/登录 CSRF。 - 代理信任: engine.SetTrustedProxies 仅信任 127.0.0.1/::1。 外部直连时 X-Forwarded-For 完全不可信,防止伪造客户端 IP 绕过登录封禁或恶意封禁他人;本机 Caddy/Nginx 转发不受影响。 - CRLF 注入: Web 写信的 To/Cc/Subject 及附件文件名不再原样拼入 MIME 头。新增 sanitizeHeaderField(strip CR/LF/NUL)、 subject 按 RFC 2047 编码、附件名用 mime.FormatMediaType (RFC 2231);附件下载的 Content-Disposition 同步修复。 消息构建抽为 buildOutgoingMessage 纯函数便于测试。 - test: 新增 13 个回归测试(trustedproxy / mail_injection / oauth2_state),覆盖伪造 XFF、注入载荷、state 校验全部分支。
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -264,62 +265,8 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
||||
|
||||
// Build the email content
|
||||
fromAddr := fmt.Sprintf("%s@%s", currentUser.Username, currentUser.Domain.Name)
|
||||
messageID, rawMessage := buildOutgoingMessage(fromAddr, to, cc, subject, body, htmlBody, attachments)
|
||||
now := time.Now()
|
||||
messageID := fmt.Sprintf("<%s@mail_go>", uuid.New().String())
|
||||
|
||||
// Construct the raw email message
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("From: %s\r\n", fromAddr))
|
||||
sb.WriteString(fmt.Sprintf("To: %s\r\n", to))
|
||||
if cc != "" {
|
||||
sb.WriteString(fmt.Sprintf("Cc: %s\r\n", cc))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("Subject: %s\r\n", subject))
|
||||
sb.WriteString(fmt.Sprintf("Message-ID: %s\r\n", messageID))
|
||||
sb.WriteString(fmt.Sprintf("Date: %s\r\n", now.Format(time.RFC1123Z)))
|
||||
sb.WriteString("MIME-Version: 1.0\r\n")
|
||||
|
||||
// Attachments are wrapped in an outer multipart/mixed container.
|
||||
outerBoundary := ""
|
||||
hasAttachments := len(attachments) > 0
|
||||
if hasAttachments {
|
||||
outerBoundary = fmt.Sprintf("----=_Mixed_%s", uuid.New().String())
|
||||
sb.WriteString(fmt.Sprintf("Content-Type: multipart/mixed; boundary=\"%s\"\r\n", outerBoundary))
|
||||
sb.WriteString("\r\n")
|
||||
sb.WriteString(fmt.Sprintf("--%s\r\n", outerBoundary))
|
||||
}
|
||||
|
||||
// Build message body with multipart/alternative if HTML is present
|
||||
if htmlBody != "" {
|
||||
boundary := fmt.Sprintf("----=_Part_%s", uuid.New().String())
|
||||
sb.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary))
|
||||
sb.WriteString("\r\n")
|
||||
sb.WriteString(fmt.Sprintf("--%s\r\n", boundary))
|
||||
sb.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n")
|
||||
sb.WriteString(body)
|
||||
sb.WriteString(fmt.Sprintf("\r\n--%s\r\n", boundary))
|
||||
sb.WriteString("Content-Type: text/html; charset=utf-8\r\n\r\n")
|
||||
sb.WriteString(htmlBody)
|
||||
sb.WriteString(fmt.Sprintf("\r\n--%s--\r\n", boundary))
|
||||
} else {
|
||||
sb.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
|
||||
sb.WriteString("\r\n")
|
||||
sb.WriteString(body)
|
||||
sb.WriteString("\r\n")
|
||||
}
|
||||
|
||||
// Append attachment parts to the multipart/mixed container.
|
||||
for _, att := range attachments {
|
||||
sb.WriteString(fmt.Sprintf("--%s\r\n", outerBoundary))
|
||||
sb.WriteString(fmt.Sprintf("Content-Type: %s; name=\"%s\"\r\n", att.contentType, att.filename))
|
||||
sb.WriteString("Content-Transfer-Encoding: base64\r\n")
|
||||
sb.WriteString(fmt.Sprintf("Content-Disposition: attachment; filename=\"%s\"\r\n\r\n", att.filename))
|
||||
sb.WriteString(base64LineWrap(att.data))
|
||||
sb.WriteString("\r\n")
|
||||
}
|
||||
if hasAttachments {
|
||||
sb.WriteString(fmt.Sprintf("--%s--\r\n", outerBoundary))
|
||||
}
|
||||
|
||||
allRecipients := append(parseAddressInput(to), parseAddressInput(cc)...)
|
||||
localUsers := make([]*db.User, 0, len(allRecipients))
|
||||
@@ -367,7 +314,7 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
for _, rcpt := range externalRecipients {
|
||||
if _, err := ob.Enqueue(currentUser, fromAddr, rcpt, []byte(sb.String())); err != nil {
|
||||
if _, err := ob.Enqueue(currentUser, fromAddr, rcpt, []byte(rawMessage)); err != nil {
|
||||
c.HTML(http.StatusBadRequest, "compose", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "compose",
|
||||
@@ -395,7 +342,7 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
||||
Subject: subject,
|
||||
TextBody: body,
|
||||
HtmlBody: htmlBody,
|
||||
RawData: sb.String(),
|
||||
RawData: rawMessage,
|
||||
Date: now,
|
||||
IsRead: false,
|
||||
}
|
||||
@@ -426,7 +373,7 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
||||
Subject: subject,
|
||||
TextBody: body,
|
||||
HtmlBody: htmlBody,
|
||||
RawData: sb.String(),
|
||||
RawData: rawMessage,
|
||||
Date: now,
|
||||
IsRead: true,
|
||||
}
|
||||
@@ -483,6 +430,94 @@ func parseAddressInput(input string) []string {
|
||||
return addresses
|
||||
}
|
||||
|
||||
// sanitizeHeaderField removes CR/LF/NUL from a value destined for an RFC 5322
|
||||
// message header, preventing header injection (e.g. smuggling a Bcc or
|
||||
// Reply-To header via a crafted subject or address list).
|
||||
func sanitizeHeaderField(s string) string {
|
||||
s = strings.ReplaceAll(s, "\r", "")
|
||||
s = strings.ReplaceAll(s, "\n", "")
|
||||
s = strings.ReplaceAll(s, "\x00", "")
|
||||
return s
|
||||
}
|
||||
|
||||
// encodeSubject prepares a subject for safe inclusion as a message header:
|
||||
// header injection characters are stripped and non-ASCII content is encoded
|
||||
// per RFC 2047.
|
||||
func encodeSubject(s string) string {
|
||||
return mime.QEncoding.Encode("utf-8", sanitizeHeaderField(s))
|
||||
}
|
||||
|
||||
// formatContentDisposition builds a Content-Disposition header value for the
|
||||
// given filename, quoting/encoding it per RFC 2183/2231 (also neutralizes
|
||||
// CR/LF injection through crafted filenames).
|
||||
func formatContentDisposition(filename string) string {
|
||||
return mime.FormatMediaType("attachment", map[string]string{"filename": filename})
|
||||
}
|
||||
|
||||
// buildOutgoingMessage constructs the raw RFC 5322 message for the web
|
||||
// compose form and returns its Message-ID. All header values derived from
|
||||
// user input are sanitized to prevent CRLF header injection.
|
||||
func buildOutgoingMessage(from, to, cc, subject, body, htmlBody string, attachments []pendingAttachment) (messageID, raw string) {
|
||||
now := time.Now()
|
||||
messageID = fmt.Sprintf("<%s@mail_go>", uuid.New().String())
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("From: %s\r\n", sanitizeHeaderField(from)))
|
||||
sb.WriteString(fmt.Sprintf("To: %s\r\n", sanitizeHeaderField(to)))
|
||||
if cc != "" {
|
||||
sb.WriteString(fmt.Sprintf("Cc: %s\r\n", sanitizeHeaderField(cc)))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("Subject: %s\r\n", encodeSubject(subject)))
|
||||
sb.WriteString(fmt.Sprintf("Message-ID: %s\r\n", messageID))
|
||||
sb.WriteString(fmt.Sprintf("Date: %s\r\n", now.Format(time.RFC1123Z)))
|
||||
sb.WriteString("MIME-Version: 1.0\r\n")
|
||||
|
||||
// Attachments are wrapped in an outer multipart/mixed container.
|
||||
outerBoundary := ""
|
||||
hasAttachments := len(attachments) > 0
|
||||
if hasAttachments {
|
||||
outerBoundary = fmt.Sprintf("----=_Mixed_%s", uuid.New().String())
|
||||
sb.WriteString(fmt.Sprintf("Content-Type: multipart/mixed; boundary=\"%s\"\r\n", outerBoundary))
|
||||
sb.WriteString("\r\n")
|
||||
sb.WriteString(fmt.Sprintf("--%s\r\n", outerBoundary))
|
||||
}
|
||||
|
||||
// Build message body with multipart/alternative if HTML is present
|
||||
if htmlBody != "" {
|
||||
boundary := fmt.Sprintf("----=_Part_%s", uuid.New().String())
|
||||
sb.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary))
|
||||
sb.WriteString("\r\n")
|
||||
sb.WriteString(fmt.Sprintf("--%s\r\n", boundary))
|
||||
sb.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n")
|
||||
sb.WriteString(body)
|
||||
sb.WriteString(fmt.Sprintf("\r\n--%s\r\n", boundary))
|
||||
sb.WriteString("Content-Type: text/html; charset=utf-8\r\n\r\n")
|
||||
sb.WriteString(htmlBody)
|
||||
sb.WriteString(fmt.Sprintf("\r\n--%s--\r\n", boundary))
|
||||
} else {
|
||||
sb.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
|
||||
sb.WriteString("\r\n")
|
||||
sb.WriteString(body)
|
||||
sb.WriteString("\r\n")
|
||||
}
|
||||
|
||||
// Append attachment parts to the multipart/mixed container.
|
||||
for _, att := range attachments {
|
||||
contentType := mime.FormatMediaType(att.contentType, map[string]string{"name": att.filename})
|
||||
sb.WriteString(fmt.Sprintf("--%s\r\n", outerBoundary))
|
||||
sb.WriteString(fmt.Sprintf("Content-Type: %s\r\n", contentType))
|
||||
sb.WriteString("Content-Transfer-Encoding: base64\r\n")
|
||||
sb.WriteString(fmt.Sprintf("Content-Disposition: %s\r\n\r\n", formatContentDisposition(att.filename)))
|
||||
sb.WriteString(base64LineWrap(att.data))
|
||||
sb.WriteString("\r\n")
|
||||
}
|
||||
if hasAttachments {
|
||||
sb.WriteString(fmt.Sprintf("--%s--\r\n", outerBoundary))
|
||||
}
|
||||
|
||||
return messageID, sb.String()
|
||||
}
|
||||
|
||||
// mimeTypes maps common file extensions to MIME types.
|
||||
var mimeTypes = map[string]string{
|
||||
".txt": "text/plain",
|
||||
@@ -623,7 +658,7 @@ func (h *MailHandler) DownloadAttachment(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", att.FileName))
|
||||
c.Header("Content-Disposition", formatContentDisposition(att.FileName))
|
||||
c.Data(http.StatusOK, att.ContentType, data)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user