Files
mailgo/internal/web/trustedproxy_test.go
kevin c725d0b91e 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 校验全部分支。
2026-08-19 16:31:25 +08:00

68 lines
2.2 KiB
Go

package web
// P1 #3 回归测试:客户端 IP 不可通过 X-Forwarded-For 伪造。
// 外部直连时伪造头必须被忽略(防绕过登录封禁/恶意封禁他人),
// 本机回环(反向代理)转发时必须取 X-Forwarded-For 中的真实客户端 IP。
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// doLoginFailure 触发一次登录失败(使 BanStore 按 ClientIP 记录失败计数),
// 返回使用的请求。
func doLoginFailure(t *testing.T, ws *WebServer, remoteAddr, xff string) {
t.Helper()
form := strings.NewReader("email=nobody@example.com&password=wrong")
req := httptest.NewRequest(http.MethodPost, "/login", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.RemoteAddr = remoteAddr
if xff != "" {
req.Header.Set("X-Forwarded-For", xff)
}
w := httptest.NewRecorder()
ws.Handler().ServeHTTP(w, req)
if w.Code != http.StatusOK { // 登录失败重渲染登录页
t.Fatalf("login failure status = %d, want 200", w.Code)
}
}
func TestExternalClientIPCannotBeSpoofed(t *testing.T) {
ws, stores := newTestWebServer(t, "0123456789abcdef0123456789abcdef")
// 模拟外部攻击者直连 8080 端口,伪造 X-Forwarded-For
doLoginFailure(t, ws, "203.0.113.99:5555", "1.2.3.4")
// 失败计数必须记在真实来源 IP 上
if _, err := stores.Bans.GetByIP("1.2.3.4"); err == nil {
t.Fatal("spoofed X-Forwarded-For IP must not be recorded")
}
entry, err := stores.Bans.GetByIP("203.0.113.99")
if err != nil {
t.Fatalf("real client IP should be recorded: %v", err)
}
if entry.FailCount != 1 {
t.Fatalf("fail count = %d, want 1", entry.FailCount)
}
}
func TestLoopbackProxyXFFIsHonored(t *testing.T) {
ws, stores := newTestWebServer(t, "0123456789abcdef0123456789abcdef")
// 模拟本机 Caddy/Nginx 转发:RemoteAddr 是回环,XFF 是真实客户端
doLoginFailure(t, ws, "127.0.0.1:5555", "198.51.100.7")
entry, err := stores.Bans.GetByIP("198.51.100.7")
if err != nil {
t.Fatalf("proxied client IP should be recorded: %v", err)
}
if entry.FailCount != 1 {
t.Fatalf("fail count = %d, want 1", entry.FailCount)
}
if _, err := stores.Bans.GetByIP("127.0.0.1"); err == nil {
t.Fatal("proxy's own IP should not be recorded")
}
}