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:
2026-08-19 16:31:25 +08:00
parent 551ed981de
commit c725d0b91e
8 changed files with 559 additions and 82 deletions
+94 -59
View File
@@ -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)
}