From ef0786c4b4d118e78c9c9856100884f919f06b00 Mon Sep 17 00:00:00 2001 From: clawbot Date: Sun, 15 Feb 2026 21:14:12 -0800 Subject: [PATCH] fix: extract real client IP from proxy headers (X-Real-IP / X-Forwarded-For) Behind a reverse proxy like Traefik, RemoteAddr always contains the proxy's IP. Add realIP() helper that checks X-Real-IP first, then the first entry of X-Forwarded-For, falling back to RemoteAddr. Update both LoginRateLimit and Logging middleware to use realIP(). Add comprehensive tests for the new function. Fixes #12 --- internal/middleware/middleware.go | 27 +++++++++- internal/middleware/realip_test.go | 83 ++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 internal/middleware/realip_test.go diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index 56e8707..daf15f1 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -7,6 +7,7 @@ import ( "math" "net" "net/http" + "strings" "sync" "time" @@ -90,7 +91,7 @@ func (m *Middleware) Logging() func(http.Handler) http.Handler { "request_id", reqID, "referer", request.Referer(), "proto", request.Proto, - "remoteIP", ipFromHostPort(request.RemoteAddr), + "remoteIP", realIP(request), "status", lrw.statusCode, "latency_ms", latency.Milliseconds(), ) @@ -110,6 +111,28 @@ func ipFromHostPort(hostPort string) string { return host } +// realIP extracts the client's real IP address from the request, +// checking proxy headers first (trusted reverse proxy like Traefik), +// then falling back to RemoteAddr. +func realIP(r *http.Request) string { + // 1. X-Real-IP (set by Traefik/nginx) + if ip := strings.TrimSpace(r.Header.Get("X-Real-IP")); ip != "" { + return ip + } + + // 2. X-Forwarded-For: take the first (leftmost/client) IP + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + if parts := strings.SplitN(xff, ",", 2); len(parts) > 0 { //nolint:mnd + if ip := strings.TrimSpace(parts[0]); ip != "" { + return ip + } + } + } + + // 3. Fall back to RemoteAddr + return ipFromHostPort(r.RemoteAddr) +} + // CORS returns CORS middleware. func (m *Middleware) CORS() func(http.Handler) http.Handler { return cors.Handler(cors.Options{ @@ -241,7 +264,7 @@ func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler { writer http.ResponseWriter, request *http.Request, ) { - ip := ipFromHostPort(request.RemoteAddr) + ip := realIP(request) limiter := loginLimiter.getLimiter(ip) if !limiter.Allow() { diff --git a/internal/middleware/realip_test.go b/internal/middleware/realip_test.go new file mode 100644 index 0000000..3271a85 --- /dev/null +++ b/internal/middleware/realip_test.go @@ -0,0 +1,83 @@ +package middleware + +import ( + "net/http" + "testing" +) + +func TestRealIP(t *testing.T) { + tests := []struct { + name string + remoteAddr string + xRealIP string + xff string + want string + }{ + { + name: "X-Real-IP takes priority", + remoteAddr: "10.0.0.1:1234", + xRealIP: "203.0.113.5", + xff: "198.51.100.1, 10.0.0.1", + want: "203.0.113.5", + }, + { + name: "X-Forwarded-For used when no X-Real-IP", + remoteAddr: "10.0.0.1:1234", + xff: "198.51.100.1, 10.0.0.1", + want: "198.51.100.1", + }, + { + name: "X-Forwarded-For single IP", + remoteAddr: "10.0.0.1:1234", + xff: "203.0.113.10", + want: "203.0.113.10", + }, + { + name: "falls back to RemoteAddr", + remoteAddr: "192.168.1.1:5678", + want: "192.168.1.1", + }, + { + name: "RemoteAddr without port", + remoteAddr: "192.168.1.1", + want: "192.168.1.1", + }, + { + name: "X-Real-IP with whitespace", + remoteAddr: "10.0.0.1:1234", + xRealIP: " 203.0.113.5 ", + want: "203.0.113.5", + }, + { + name: "X-Forwarded-For with whitespace", + remoteAddr: "10.0.0.1:1234", + xff: " 198.51.100.1 , 10.0.0.1", + want: "198.51.100.1", + }, + { + name: "empty X-Real-IP falls through to XFF", + remoteAddr: "10.0.0.1:1234", + xRealIP: " ", + xff: "198.51.100.1", + want: "198.51.100.1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, _ := http.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = tt.remoteAddr + if tt.xRealIP != "" { + req.Header.Set("X-Real-IP", tt.xRealIP) + } + if tt.xff != "" { + req.Header.Set("X-Forwarded-For", tt.xff) + } + + got := realIP(req) + if got != tt.want { + t.Errorf("realIP() = %q, want %q", got, tt.want) + } + }) + } +}