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:
8 files changed
+559
-82
No files matched your search
@@ -0,0 +1,67 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user