diff --git a/context.go b/context.go index 3452207..e85f6f1 100644 --- a/context.go +++ b/context.go @@ -140,18 +140,54 @@ func (ctx *Context) Body() (io.ReadCloser, error) { return ctx.req.Body() } -// ClientIP returns the IP address of the client making the request. +// ClientIP returns the IP address of the direct TCP connection peer. func (ctx *Context) ClientIP() string { - proxyIp := ctx.Header("X-Forwarded-For") - if proxyIp != "" { - ips := strings.Split(proxyIp, ",") - if len(ips) > 0 { - proxyIp = strings.TrimSpace(ips[0]) - return proxyIp + return ctx.req.ClientIP() +} + +// ClientIPs collects all available client IP information from the request, +// combining proxy headers with the direct connection IP. The returned slice +// is deduplicated and preserves the following priority order: +// +// 1. X-Forwarded-For chain (original client first, each proxy in order) +// 2. X-Real-IP (only if not already present from X-Forwarded-For) +// 3. Direct connection IP (only if not already present from the headers above) +// +// The result is never nil; at minimum it contains the direct connection IP. +func (ctx *Context) ClientIPs() []string { + ips := make([]string, 0) + recorded := make(map[string]struct{}) + + xff := ctx.Header("X-Forwarded-For") + if xff != "" { + parts := strings.Split(xff, ",") + for _, part := range parts { + ip := strings.TrimSpace(part) + if ip == "" { + continue + } + if _, ok := recorded[ip]; ok { + continue + } + ips = append(ips, ip) + recorded[ip] = struct{}{} + } + } + realIP := ctx.Header("X-Real-IP") + if realIP != "" { + if _, ok := recorded[realIP]; !ok { + ips = append(ips, realIP) + recorded[realIP] = struct{}{} } } - return ctx.req.ClientIP() + clientIP := ctx.ClientIP() + if _, ok := recorded[clientIP]; !ok { + ips = append(ips, clientIP) + } + + // TODO: add trusted proxy validation to filter out untrusted IPs from the headers. + return ips } // ContentLength returns the length of the request body in bytes. diff --git a/context_test.go b/context_test.go index e0e2625..bc49b09 100644 --- a/context_test.go +++ b/context_test.go @@ -290,18 +290,108 @@ func TestContext_ClientIP(t *testing.T) { ctx := getDefaultContext() req := ctx.Request().(*Request) + // ClientIP returns the direct connection IP, independent of proxy headers. if ip := ctx.ClientIP(); ip != "127.0.0.1" { t.Errorf("Expected ClientIP '127.0.0.1', got %v", ip) } - // X-Forwarded-For takes precedence + // Setting proxy headers does NOT affect ClientIP. req.headers.Set("X-Forwarded-For", "203.0.113.1, 10.0.0.1") - if ip := ctx.ClientIP(); ip != "203.0.113.1" { - t.Errorf("Expected ClientIP to be '203.0.113.1', got %v", ip) - } - req.headers.Del("X-Forwarded-For") + req.headers.Set("X-Real-IP", "198.51.100.1") if ip := ctx.ClientIP(); ip != "127.0.0.1" { - t.Errorf("Expected ClientIP to fallback to '127.0.0.1', got %v", ip) + t.Errorf("Expected ClientIP to remain '127.0.0.1' regardless of proxy headers, got %v", ip) + } + + // Changing the underlying request's client IP is reflected. + req.clientIP = "192.168.1.1" + if ip := ctx.ClientIP(); ip != "192.168.1.1" { + t.Errorf("Expected ClientIP '192.168.1.1', got %v", ip) + } +} + +func TestContext_ClientIPs(t *testing.T) { + tests := []struct { + name string + xff string + realIP string + clientIP string + want []string + }{ + { + name: "no proxy headers", + xff: "", + realIP: "", + clientIP: "10.0.0.1", + want: []string{"10.0.0.1"}, + }, + { + name: "X-Forwarded-For only", + xff: "203.0.113.1, 198.51.100.2, 10.0.0.1", + realIP: "", + clientIP: "10.0.0.1", + want: []string{"203.0.113.1", "198.51.100.2", "10.0.0.1"}, + }, + { + name: "X-Real-IP only", + xff: "", + realIP: "203.0.113.1", + clientIP: "10.0.0.1", + want: []string{"203.0.113.1", "10.0.0.1"}, + }, + { + name: "both headers deduplicated", + xff: "203.0.113.1, 198.51.100.2", + realIP: "203.0.113.1", + clientIP: "198.51.100.2", + want: []string{"203.0.113.1", "198.51.100.2"}, + }, + { + name: "XFF with whitespace", + xff: " 203.0.113.1 , 198.51.100.2 ", + realIP: "", + clientIP: "10.0.0.1", + want: []string{"203.0.113.1", "198.51.100.2", "10.0.0.1"}, + }, + { + name: "XFF duplicated", + xff: " 203.0.113.1 , 203.0.113.1 , 198.51.100.2 ", + realIP: "", + clientIP: "10.0.0.1", + want: []string{"203.0.113.1", "198.51.100.2", "10.0.0.1"}, + }, + { + name: "XFF with empty entries", + xff: "203.0.113.1, , 198.51.100.2", + realIP: "", + clientIP: "10.0.0.1", + want: []string{"203.0.113.1", "198.51.100.2", "10.0.0.1"}, + }, + { + name: "all three distinct", + xff: "203.0.113.1", + realIP: "198.51.100.2", + clientIP: "10.0.0.1", + want: []string{"203.0.113.1", "198.51.100.2", "10.0.0.1"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := getDefaultContext() + req := ctx.Request().(*Request) + req.clientIP = tt.clientIP + if tt.xff != "" { + req.headers.Set("X-Forwarded-For", tt.xff) + } + if tt.realIP != "" { + req.headers.Set("X-Real-IP", tt.realIP) + } + + got := ctx.ClientIPs() + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ClientIPs() = %v, want %v", got, tt.want) + } + }) } }