diff --git a/docs/content/docs/en/operations/reverse-proxy.mdx b/docs/content/docs/en/operations/reverse-proxy.mdx index 77662d8fc..5f990e023 100644 --- a/docs/content/docs/en/operations/reverse-proxy.mdx +++ b/docs/content/docs/en/operations/reverse-proxy.mdx @@ -38,7 +38,9 @@ and it just works: ```text title="Caddyfile" panel.example.com { - reverse_proxy 127.0.0.1:2053 + reverse_proxy 127.0.0.1:2053 { + header_up X-Real-IP {remote_host} + } } ``` diff --git a/docs/content/docs/fa/operations/reverse-proxy.mdx b/docs/content/docs/fa/operations/reverse-proxy.mdx index 01118f431..9647182fc 100644 --- a/docs/content/docs/fa/operations/reverse-proxy.mdx +++ b/docs/content/docs/fa/operations/reverse-proxy.mdx @@ -38,7 +38,9 @@ Caddy گواهی‌ها را برای شما دریافت و تمدید می‌ ```text title="Caddyfile" panel.example.com { - reverse_proxy 127.0.0.1:2053 + reverse_proxy 127.0.0.1:2053 { + header_up X-Real-IP {remote_host} + } } ``` diff --git a/docs/content/docs/ru/operations/reverse-proxy.mdx b/docs/content/docs/ru/operations/reverse-proxy.mdx index 5c0fcf182..cf0773be1 100644 --- a/docs/content/docs/ru/operations/reverse-proxy.mdx +++ b/docs/content/docs/ru/operations/reverse-proxy.mdx @@ -39,7 +39,9 @@ Caddy получает и продлевает сертификаты за ва ```text title="Caddyfile" panel.example.com { - reverse_proxy 127.0.0.1:2053 + reverse_proxy 127.0.0.1:2053 { + header_up X-Real-IP {remote_host} + } } ``` diff --git a/docs/content/docs/zh/operations/reverse-proxy.mdx b/docs/content/docs/zh/operations/reverse-proxy.mdx index 53822013f..37f5bf5d4 100644 --- a/docs/content/docs/zh/operations/reverse-proxy.mdx +++ b/docs/content/docs/zh/operations/reverse-proxy.mdx @@ -33,7 +33,9 @@ Caddy 会为你获取并续期证书——只需让 Caddyfile 指向面板,它 ```text title="Caddyfile" panel.example.com { - reverse_proxy 127.0.0.1:2053 + reverse_proxy 127.0.0.1:2053 { + header_up X-Real-IP {remote_host} + } } ``` diff --git a/docs/lib/xray/reverse-proxy.test.ts b/docs/lib/xray/reverse-proxy.test.ts index 219f2a2c1..f4790880d 100644 --- a/docs/lib/xray/reverse-proxy.test.ts +++ b/docs/lib/xray/reverse-proxy.test.ts @@ -31,6 +31,15 @@ describe('buildProxyConfig — caddy', () => { const cfg = buildProxyConfig({ ...base, server: 'caddy', panelPath: '/' }); expect(cfg).toContain('reverse_proxy 127.0.0.1:2053'); }); + + // Caddy forwards a client-sent X-Real-IP unchanged, and the panel's login + // limiter would key on it; only an overwrite gives the address Caddy saw. + it('overwrites X-Real-IP with the address Caddy saw', () => { + for (const panelPath of ['/', '/panel']) { + const cfg = buildProxyConfig({ ...base, server: 'caddy', panelPath }); + expect(cfg).toContain('header_up X-Real-IP {remote_host}'); + } + }); }); describe('buildCertCommand', () => { diff --git a/docs/lib/xray/reverse-proxy.ts b/docs/lib/xray/reverse-proxy.ts index f045a7d61..ad94b6c17 100644 --- a/docs/lib/xray/reverse-proxy.ts +++ b/docs/lib/xray/reverse-proxy.ts @@ -49,14 +49,12 @@ function buildNginx(o: ReverseProxyOptions): string { function buildCaddy(o: ReverseProxyOptions): string { const path = normalizePath(o.panelPath); - if (path === '/') { - return `${o.domain} { - reverse_proxy 127.0.0.1:${o.panelPort} -}`; - } - const matcher = `${path.replace(/\/$/, '')}/*`; + const matcher = path === '/' ? '' : `${path.replace(/\/$/, '')}/* `; + // Caddy forwards a client-sent X-Real-IP as is; the panel's login limiter reads it. return `${o.domain} { - reverse_proxy ${matcher} 127.0.0.1:${o.panelPort} + reverse_proxy ${matcher}127.0.0.1:${o.panelPort} { + header_up X-Real-IP {remote_host} + } }`; } diff --git a/internal/web/controller/util.go b/internal/web/controller/util.go index aa14134f9..c693abf4a 100644 --- a/internal/web/controller/util.go +++ b/internal/web/controller/util.go @@ -22,24 +22,43 @@ func getRemoteIp(c *gin.Context) string { if !ok { return "unknown" } - - if isTrustedProxy(remoteIP) { - if ip, ok := extractTrustedIP(c.GetHeader("X-Real-IP")); ok { - return ip - } - - if xff := c.GetHeader("X-Forwarded-For"); xff != "" { - for part := range strings.SplitSeq(xff, ",") { - if ip, ok := extractTrustedIP(part); ok { - return ip - } - } - } + if !isTrustedProxy(remoteIP) { + return remoteIP } + realIP, hasRealIP := extractTrustedIP(c.GetHeader("X-Real-IP")) + forwardedIP, hasForwarded := forwardedForClient(c.GetHeader("X-Forwarded-For")) + switch { + case hasRealIP && hasForwarded && realIP != forwardedIP: + // A proxy rewrites at most one of the two, so the other is the client's + // own claim; neither may pick the identity the login limiter keys on. + return remoteIP + case hasForwarded: + return forwardedIP + case hasRealIP: + return realIP + } return remoteIP } +// forwardedForClient walks X-Forwarded-For from the proxy end: entries a client sent +// come first, so the first hop that is not a trusted proxy is the address one saw. +func forwardedForClient(xff string) (string, bool) { + parts := strings.Split(xff, ",") + client := "" + for i := len(parts) - 1; i >= 0; i-- { + ip, ok := extractTrustedIP(parts[i]) + if !ok { + break + } + client = ip + if !isTrustedProxy(ip) { + break + } + } + return client, client != "" +} + func isTrustedForwardedRequest(c *gin.Context) bool { remoteIP, ok := extractTrustedIP(c.Request.RemoteAddr) return ok && isTrustedProxy(remoteIP) diff --git a/internal/web/controller/util_test.go b/internal/web/controller/util_test.go index 048faa7b3..fb5acce72 100644 --- a/internal/web/controller/util_test.go +++ b/internal/web/controller/util_test.go @@ -33,6 +33,35 @@ func TestGetRemoteIpHonorsForwardedHeadersFromTrustedLoopbackProxy(t *testing.T) } } +// Caddy and HAProxy set X-Forwarded-For but pass the client's own X-Real-IP +// through, so a client could pick its login-limiter bucket per request. +func TestGetRemoteIpDistrustsRealIPThatContradictsForwardedFor(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodGet, "/", nil) + c.Request.RemoteAddr = "127.0.0.1:12345" + c.Request.Header.Set("X-Forwarded-For", "203.0.113.5") + c.Request.Header.Set("X-Real-IP", "198.51.100.1") + + if got := getRemoteIp(c); got != "127.0.0.1" { + t.Fatalf("remote IP = %q, want the proxy address when the forwarded headers disagree", got) + } +} + +// An appending proxy keeps whatever X-Forwarded-For the client sent in front of +// the address it saw, so only the hop next to the trusted proxies is real. +func TestGetRemoteIpReadsForwardedForFromTheProxyEnd(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodGet, "/", nil) + c.Request.RemoteAddr = "127.0.0.1:12345" + c.Request.Header.Set("X-Forwarded-For", "198.51.100.1, 203.0.113.5") + + if got := getRemoteIp(c); got != "203.0.113.5" { + t.Fatalf("remote IP = %q, want the hop the trusted proxy appended", got) + } +} + func TestResolveHostPrefersForwardedHostOverRealIP(t *testing.T) { gin.SetMode(gin.TestMode) c, _ := gin.CreateTestContext(httptest.NewRecorder())