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
+56 -2
View File
@@ -1,6 +1,9 @@
package handlers
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"fmt"
"log"
"net/http"
@@ -218,6 +221,36 @@ func (h *AuthHandler) LDAPLogin(c *gin.Context) {
c.Redirect(302, "/inbox")
}
// OAuth2 state cookie 配置。state 用于防止登录 CSRF / 授权码注入:
// 发起授权时下发随机值,回调时必须原样带回。
//
// 注意 state 不能放进主会话 cookie:主会话是 SameSite=Strict
// OAuth2 回调是从 IdP 发起的跨站顶级导航,浏览器不会携带 Strict
// cookie,因此使用独立的短期 SameSite=Lax cookie。
const (
oauth2StateCookie = "mail_go_oauth2_state"
oauth2StateMaxAge = 600 // 秒,10 分钟内完成授权流程
oauth2StateRandLen = 16 // 随机字节数(hex 编码后 32 字符)
)
// randomOAuth2State generates a hex-encoded cryptographically random state.
func randomOAuth2State() (string, error) {
buf := make([]byte, oauth2StateRandLen)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("生成 OAuth2 state 失败: %w", err)
}
return hex.EncodeToString(buf), nil
}
// oauth2LoginVars 是登录模板所需的公共变量。
func (h *AuthHandler) oauth2LoginVars() gin.H {
return gin.H{
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
}
}
// OAuth2Start redirects to the OAuth2 provider's authorization page.
func (h *AuthHandler) OAuth2Start(c *gin.Context) {
if !h.authCfg.OAuth2Enabled {
@@ -226,8 +259,13 @@ func (h *AuthHandler) OAuth2Start(c *gin.Context) {
}
provider := auth.NewOAuth2Provider(h.authCfg)
// Use a simple state for CSRF protection (in production, use a random token)
state := "mailgo_oauth2_state"
state, err := randomOAuth2State()
if err != nil {
log.Printf("生成 OAuth2 state 失败: %v", err)
c.String(http.StatusInternalServerError, "OAuth2 登录暂不可用,请稍后重试")
return
}
c.SetCookie(oauth2StateCookie, state, oauth2StateMaxAge, "/auth/oauth2", "", true, true)
c.Redirect(http.StatusFound, provider.GetAuthURL(state))
}
@@ -238,6 +276,22 @@ func (h *AuthHandler) OAuth2Callback(c *gin.Context) {
return
}
// 校验 state:必须与发起授权时下发的随机值一致(常量时间比较)。
// 缺失或不匹配视为登录 CSRF / 授权码注入,直接拒绝。
cookieState, cookieErr := c.Cookie(oauth2StateCookie)
reqState := c.Query("state")
if cookieErr != nil || reqState == "" ||
subtle.ConstantTimeCompare([]byte(cookieState), []byte(reqState)) != 1 {
c.HTML(http.StatusForbidden, "login", func() gin.H {
v := h.oauth2LoginVars()
v["error"] = "OAuth2 state 校验失败,请重新发起登录"
return v
}())
return
}
// state 一次性使用:无论后续成败都立即失效
c.SetCookie(oauth2StateCookie, "", -1, "/auth/oauth2", "", true, true)
code := c.Query("code")
if code == "" {
c.HTML(200, "login", gin.H{