- 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 校验全部分支。
68 lines
2.2 KiB
Go
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")
|
|
}
|
|
}
|