Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 44 additions & 8 deletions context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, ",")
Comment on lines +158 to +163
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.
Expand Down
102 changes: 96 additions & 6 deletions context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
}

Expand Down