diff --git a/internal/sub/forwarded_trust_test.go b/internal/sub/forwarded_trust_test.go index a0eb1d1c5..954f2e776 100644 --- a/internal/sub/forwarded_trust_test.go +++ b/internal/sub/forwarded_trust_test.go @@ -154,6 +154,29 @@ func TestResolveRequest_GatesRealIPFallback(t *testing.T) { } } +// nginx often sets X-Real-IP without X-Forwarded-Host; a trusted proxy's X-Real-IP +// used to put the subscriber's public IP into subscription "server" fields (#6589). +func TestResolveRequest_IgnoresRealIPEvenWhenTrusted(t *testing.T) { + initSubDB(t) + // Empty / shipped-default CIDRs → forwardedHeadersTrusted returns true. + s := &SubService{} + + c := requestFrom(t, "10.1.2.3:44000", map[string]string{ + "X-Real-IP": "198.51.100.7", + }) + _, host, hostWithPort, hostHeader := s.ResolveRequest(c) + + if host != "panel.example.com" { + t.Errorf("host = %q, want request host (not X-Real-IP)", host) + } + if hostWithPort != "panel.example.com:2096" { + t.Errorf("hostWithPort = %q, want request Host", hostWithPort) + } + if hostHeader != "panel.example.com" { + t.Errorf("hostHeader = %q, want request host (not X-Real-IP)", hostHeader) + } +} + func TestHasForwardedHeaders(t *testing.T) { tests := []struct { name string diff --git a/internal/sub/service.go b/internal/sub/service.go index e18274fa6..3e14efdac 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -2992,8 +2992,8 @@ type PageData struct { Emails []string } -// ResolveRequest extracts scheme and host info from request/headers consistently. // ResolveRequest extracts scheme, host, and header information from an HTTP request. +// X-Real-IP names the visitor, never the panel, so it is no host source (#6589). func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string, hostWithPort string, hostHeader string) { trusted := s.forwardedHeadersTrusted(c) if !trusted { @@ -3012,13 +3012,10 @@ func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string, scheme = "https" } - // base host (no port) + // base host (no port): trusted X-Forwarded-Host, then the dialed request Host. if h, err := getHostFromXFH(forwarded("X-Forwarded-Host")); err == nil && h != "" { host = h } - if host == "" { - host = forwarded("X-Real-IP") - } if host == "" { var err error host, _, err = net.SplitHostPort(c.Request.Host) @@ -3038,9 +3035,6 @@ func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string, // header display host hostHeader = forwarded("X-Forwarded-Host") - if hostHeader == "" { - hostHeader = forwarded("X-Real-IP") - } if hostHeader == "" { hostHeader = host } diff --git a/internal/web/controller/inbound.go b/internal/web/controller/inbound.go index 313aa391c..ca4879dc1 100644 --- a/internal/web/controller/inbound.go +++ b/internal/web/controller/inbound.go @@ -442,11 +442,8 @@ func (a *InboundController) importInbound(c *gin.Context) { notifyClientsChanged() } -// resolveHost mirrors what sub.SubService.ResolveRequest does for the host -// field: prefers X-Forwarded-Host (first entry of any list, port stripped), -// then X-Real-IP, then the host portion of c.Request.Host. Keeping it in the -// controller layer means the service interface stays HTTP-agnostic — service -// methods receive a plain host string instead of a *gin.Context. +// resolveHost mirrors SubService.ResolveRequest's host: trusted X-Forwarded-Host, +// else the dialed request Host. X-Real-IP names the visitor, not the panel (#6589). func resolveHost(c *gin.Context) string { if isTrustedForwardedRequest(c) { if h := strings.TrimSpace(c.GetHeader("X-Forwarded-Host")); h != "" { @@ -458,9 +455,6 @@ func resolveHost(c *gin.Context) string { } return h } - if h := c.GetHeader("X-Real-IP"); h != "" { - return h - } } if h, _, err := net.SplitHostPort(c.Request.Host); err == nil { return h diff --git a/internal/web/controller/util_test.go b/internal/web/controller/util_test.go index a8347f9fa..048faa7b3 100644 --- a/internal/web/controller/util_test.go +++ b/internal/web/controller/util_test.go @@ -32,3 +32,30 @@ func TestGetRemoteIpHonorsForwardedHeadersFromTrustedLoopbackProxy(t *testing.T) t.Fatalf("remote IP = %q, want forwarded client IP", got) } } + +func TestResolveHostPrefersForwardedHostOverRealIP(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodGet, "/", nil) + c.Request.Host = "panel.example.com:2053" + c.Request.RemoteAddr = "127.0.0.1:12345" + c.Request.Header.Set("X-Forwarded-Host", "sub.example.net:443") + c.Request.Header.Set("X-Real-IP", "198.51.100.7") + + if got := resolveHost(c); got != "sub.example.net" { + t.Fatalf("resolveHost = %q, want X-Forwarded-Host", got) + } +} + +func TestResolveHostIgnoresRealIPFromTrustedProxy(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodGet, "/", nil) + c.Request.Host = "panel.example.com:2053" + c.Request.RemoteAddr = "127.0.0.1:12345" + c.Request.Header.Set("X-Real-IP", "198.51.100.7") + + if got := resolveHost(c); got != "panel.example.com" { + t.Fatalf("resolveHost = %q, want request host (not X-Real-IP)", got) + } +}