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) + } + }) + } +}