From f819a77d832aad0baeae20d4bbd05564bb3968f3 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Fri, 24 Jul 2026 08:34:57 +0800 Subject: [PATCH] =?UTF-8?q?fix(admin):=20=E7=99=BB=E5=BD=95=E5=AE=A1?= =?UTF-8?q?=E8=AE=A1=E8=AE=B0=E5=BD=95=E7=9C=9F=E5=AE=9E=E6=9D=A5=E6=BA=90?= =?UTF-8?q?=20IP(=E7=BB=8F=E6=9C=AC=E6=9C=BA=E5=8F=8D=E4=BB=A3=E6=97=B6?= =?UTF-8?q?=E5=8F=96=20XFF=20=E6=9C=AB=E8=B7=B3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 管理后台经 caddy mTLS 网关(同机 loopback 反代 127.0.0.1:9444)对外后, LoginSubmit 里 hostOnly(r.RemoteAddr) 恒为 127.0.0.1,admin_login_ok/fail 的 ip 字段失去溯源价值。 新增 realIP():仅当 TCP 对端是 loopback(请求确实来自本机可信反代)才信 X-Forwarded-For,且取**最后一跳**(Caddy 把真实 TCP 对端追加在末位,更早的 段可被客户端伪造预置);直连、XFF 缺失或非法值一律回退 TCP 对端。 IPAllow 白名单中间件刻意不变——安全闸继续只看 RemoteAddr,不受任何代理头 影响(维持原注释声明的边界)。 测试:TestRealIP 七例(直连/伪造头/单跳/多跳/缺失/非法/IPv6); go test ./internal/admin 全绿。(internal/store 迁移彩排 3 例失败为 HEAD 既有——主仓干净树复现,与本次无关。) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu --- server/internal/admin/handlers.go | 2 +- server/internal/admin/mw_ipallow.go | 26 +++++++++++++++++++ server/internal/admin/realip_test.go | 37 ++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 server/internal/admin/realip_test.go diff --git a/server/internal/admin/handlers.go b/server/internal/admin/handlers.go index 278c783..fc5ea30 100644 --- a/server/internal/admin/handlers.go +++ b/server/internal/admin/handlers.go @@ -61,7 +61,7 @@ func (h *Handlers) LoginSubmit(w http.ResponseWriter, r *http.Request) { username := strings.TrimSpace(r.PostFormValue("username")) password := r.PostFormValue("password") code := strings.TrimSpace(r.PostFormValue("totp")) - ip := hostOnly(r.RemoteAddr) + ip := realIP(r) // 经本机 caddy 反代时取 XFF 末跳,否则 TCP 对端(见 mw_ipallow.go) sid, _, err := h.auth.Login(r.Context(), username, password, code, ip) if err != nil { diff --git a/server/internal/admin/mw_ipallow.go b/server/internal/admin/mw_ipallow.go index be974d6..8a0c920 100644 --- a/server/internal/admin/mw_ipallow.go +++ b/server/internal/admin/mw_ipallow.go @@ -3,6 +3,7 @@ package admin import ( "net" "net/http" + "strings" ) // IPAllow is middleware that rejects any request whose source IP is not within @@ -58,3 +59,28 @@ func hostOnly(remoteAddr string) string { } return host } + +// realIP returns the client IP for **audit logging** (admin_login_ok 等)。 +// +// 与上面 IPAllow 的取址原则刻意不同:白名单闸继续只看 TCP 对端(不可伪造); +// 而审计日志要的是"人从哪来"——经本机反代(caddy mTLS 网关在同机回环上反代 +// 127.0.0.1:9444)时 RemoteAddr 恒为 loopback,没有溯源价值。仅当 TCP 对端是 +// loopback(即请求确实来自本机可信反代)才信 X-Forwarded-For,且取**最后一跳** +// (Caddy 把真实 TCP 对端追加在末位;更早的段可被客户端伪造预置,不可信)。 +func realIP(r *http.Request) string { + peer := hostOnly(r.RemoteAddr) + ip := net.ParseIP(peer) + if ip == nil || !ip.IsLoopback() { + return peer + } + xff := r.Header.Get("X-Forwarded-For") + if xff == "" { + return peer + } + parts := strings.Split(xff, ",") + last := strings.TrimSpace(parts[len(parts)-1]) + if net.ParseIP(last) == nil { + return peer + } + return last +} diff --git a/server/internal/admin/realip_test.go b/server/internal/admin/realip_test.go new file mode 100644 index 0000000..dc6a51e --- /dev/null +++ b/server/internal/admin/realip_test.go @@ -0,0 +1,37 @@ +package admin + +import ( + "net/http/httptest" + "testing" +) + +// realIP:仅当 TCP 对端为 loopback(本机可信反代,如 caddy mTLS 网关)时才信 +// X-Forwarded-For 的最后一跳;其余情况一律用 TCP 对端,防止伪造头污染审计。 +func TestRealIP(t *testing.T) { + cases := []struct { + name string + remoteAddr string + xff string + want string + }{ + {"直连无代理", "203.0.113.7:52011", "", "203.0.113.7"}, + {"直连时伪造 XFF 不可信", "203.0.113.7:52011", "1.2.3.4", "203.0.113.7"}, + {"经本机反代取 XFF 最后一跳", "127.0.0.1:38200", "198.51.100.23", "198.51.100.23"}, + {"多跳取最后一跳(前段可伪造)", "127.0.0.1:38200", "6.6.6.6, 198.51.100.23", "198.51.100.23"}, + {"本机反代但无 XFF 回退 loopback", "127.0.0.1:38200", "", "127.0.0.1"}, + {"XFF 非法值回退", "127.0.0.1:38200", "not-an-ip", "127.0.0.1"}, + {"IPv6 loopback 反代", "[::1]:38200", "198.51.100.23", "198.51.100.23"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + r := httptest.NewRequest("POST", "/login", nil) + r.RemoteAddr = c.remoteAddr + if c.xff != "" { + r.Header.Set("X-Forwarded-For", c.xff) + } + if got := realIP(r); got != c.want { + t.Errorf("realIP(remote=%s, xff=%q) = %q, want %q", c.remoteAddr, c.xff, got, c.want) + } + }) + } +}