diff --git a/backend/internal/httpapi/client_ip.go b/backend/internal/httpapi/client_ip.go new file mode 100644 index 0000000..501eff8 --- /dev/null +++ b/backend/internal/httpapi/client_ip.go @@ -0,0 +1,63 @@ +package httpapi + +import ( + "net" + "net/http" + "strings" +) + +func extractClientIP(r *http.Request) *string { + if r == nil { + return nil + } + + // Priority headers commonly set by reverse proxies, load balancers, and CDNs. + headers := []string{ + "CF-Connecting-IP", // Cloudflare + "True-Client-IP", // Cloudflare Enterprise, Akamai + "X-Real-IP", // Nginx, Traefik + } + + for _, header := range headers { + if val := strings.TrimSpace(r.Header.Get(header)); val != "" { + if ip := parseIP(val); ip != "" { + return &ip + } + } + } + + // X-Forwarded-For can contain multiple comma-separated IPs: client, proxy1, proxy2. The original client IP is the first entry. + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + for _, part := range strings.Split(xff, ",") { + if ip := parseIP(part); ip != "" { + return &ip + } + } + } + + // Fallback to RemoteAddr (typically "host:port" or "[ipv6]:port"). + if r.RemoteAddr != "" { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + if ip := parseIP(host); ip != "" { + return &ip + } + } + + return nil +} + +func parseIP(raw string) string { + raw = strings.TrimSpace(raw) + raw = strings.TrimPrefix(raw, "[") + raw = strings.TrimSuffix(raw, "]") + + ip := net.ParseIP(raw) + if ip == nil { + return "" + } + + return ip.String() +} diff --git a/backend/internal/httpapi/client_ip_test.go b/backend/internal/httpapi/client_ip_test.go new file mode 100644 index 0000000..7690045 --- /dev/null +++ b/backend/internal/httpapi/client_ip_test.go @@ -0,0 +1,143 @@ +package httpapi + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestExtractClientIP(t *testing.T) { + tests := []struct { + name string + request *http.Request + headers map[string]string + remoteAddr string + wantIP *string + }{ + { + name: "nil request", + request: nil, + wantIP: nil, + }, + { + name: "CF-Connecting-IP takes precedence", + headers: map[string]string{ + "CF-Connecting-IP": "203.0.113.10", + "X-Real-IP": "198.51.100.20", + "X-Forwarded-For": "192.0.2.30", + }, + remoteAddr: "10.0.0.1:54321", + wantIP: ptr("203.0.113.10"), + }, + { + name: "True-Client-IP takes precedence over X-Real-IP", + headers: map[string]string{ + "True-Client-IP": "203.0.113.11", + "X-Real-IP": "198.51.100.20", + "X-Forwarded-For": "192.0.2.30", + }, + remoteAddr: "10.0.0.1:54321", + wantIP: ptr("203.0.113.11"), + }, + { + name: "X-Real-IP takes precedence over X-Forwarded-For", + headers: map[string]string{ + "X-Real-IP": "198.51.100.20", + "X-Forwarded-For": "192.0.2.30", + }, + remoteAddr: "10.0.0.1:54321", + wantIP: ptr("198.51.100.20"), + }, + { + name: "X-Forwarded-For first entry is extracted", + headers: map[string]string{ + "X-Forwarded-For": "192.0.2.30, 10.0.0.2, 172.16.0.5", + }, + remoteAddr: "10.0.0.1:54321", + wantIP: ptr("192.0.2.30"), + }, + { + name: "X-Forwarded-For single entry", + headers: map[string]string{ + "X-Forwarded-For": " 192.0.2.31 ", + }, + remoteAddr: "10.0.0.1:54321", + wantIP: ptr("192.0.2.31"), + }, + { + name: "X-Forwarded-For skips invalid first entry to find valid IP", + headers: map[string]string{ + "X-Forwarded-For": "unknown, 192.0.2.32", + }, + remoteAddr: "10.0.0.1:54321", + wantIP: ptr("192.0.2.32"), + }, + { + name: "Fallback to RemoteAddr with port", + headers: map[string]string{}, + remoteAddr: "192.168.1.100:12345", + wantIP: ptr("192.168.1.100"), + }, + { + name: "Fallback to RemoteAddr without port", + headers: map[string]string{}, + remoteAddr: "192.168.1.101", + wantIP: ptr("192.168.1.101"), + }, + { + name: "IPv6 in RemoteAddr with port", + headers: map[string]string{}, + remoteAddr: "[2001:db8::1]:8080", + wantIP: ptr("2001:db8::1"), + }, + { + name: "IPv6 in header", + headers: map[string]string{ + "X-Real-IP": "2001:db8::2", + }, + remoteAddr: "10.0.0.1:54321", + wantIP: ptr("2001:db8::2"), + }, + { + name: "Invalid headers fall back to RemoteAddr", + headers: map[string]string{ + "X-Real-IP": "not-an-ip", + "X-Forwarded-For": "also-not-an-ip", + }, + remoteAddr: "192.168.1.102:443", + wantIP: ptr("192.168.1.102"), + }, + { + name: "No headers and empty RemoteAddr", + headers: map[string]string{}, + remoteAddr: "", + wantIP: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var req *http.Request + if tt.name != "nil request" { + req = httptest.NewRequest(http.MethodGet, "/", nil) + for k, v := range tt.headers { + req.Header.Set(k, v) + } + req.RemoteAddr = tt.remoteAddr + } + + got := extractClientIP(req) + if (got == nil) != (tt.wantIP == nil) { + t.Fatalf("extractClientIP() = %v, want %v", got, tt.wantIP) + } + if got != nil && tt.wantIP != nil && *got != *tt.wantIP { + t.Errorf("extractClientIP() = %q, want %q", *got, *tt.wantIP) + } + }) + } +} + +func ptr(s string) *string { + return &s +} + diff --git a/backend/internal/httpapi/handler.go b/backend/internal/httpapi/handler.go index 74936d7..5c519a4 100644 --- a/backend/internal/httpapi/handler.go +++ b/backend/internal/httpapi/handler.go @@ -60,6 +60,7 @@ func NewHandler( func (h *Handler) Convert(w http.ResponseWriter, r *http.Request) { startedAt := h.now() requestID := h.newRequestID() + clientIP := extractClientIP(r) w.Header().Set("X-Request-ID", requestID) w.Header().Set("X-Content-Type-Options", "nosniff") @@ -89,6 +90,7 @@ func (h *Handler) Convert(w http.ResponseWriter, r *http.Request) { RequestID: requestID, CreatedAt: startedAt, Duration: duration, + ClientIP: clientIP, Status: outcome.auditStatus, MaskedCard: card.Mask(cardNumber), ResponseJSON: audit.SanitizeResponse( @@ -102,11 +104,17 @@ func (h *Handler) Convert(w http.ResponseWriter, r *http.Request) { record.UpstreamHTTPStatus = &upstreamStatus } + var clientIPValue string + if clientIP != nil { + clientIPValue = *clientIP + } + if err := h.audits.Save(r.Context(), record); err != nil { h.logger.ErrorContext( r.Context(), "request audit failed", "request_id", requestID, + "client_ip", clientIPValue, "route", "/api/v1/card-to-iban", "http_status", http.StatusInternalServerError, "audit_status", audit.StatusInternalError, @@ -128,6 +136,7 @@ func (h *Handler) Convert(w http.ResponseWriter, r *http.Request) { r.Context(), "request completed", "request_id", requestID, + "client_ip", clientIPValue, "route", "/api/v1/card-to-iban", "http_status", outcome.httpStatus, "audit_status", outcome.auditStatus, diff --git a/backend/internal/httpapi/handler_test.go b/backend/internal/httpapi/handler_test.go index 0dbc52d..1d903d6 100644 --- a/backend/internal/httpapi/handler_test.go +++ b/backend/internal/httpapi/handler_test.go @@ -248,6 +248,10 @@ func TestHandlerConvert(t *testing.T) { t.Errorf("masked card = %q, want %q", record.MaskedCard, tt.wantMaskedCard) } + if record.ClientIP == nil || *record.ClientIP != "192.0.2.1" { + t.Errorf("client IP = %v, want 192.0.2.1", record.ClientIP) + } + if strings.Contains(string(record.ResponseJSON), "6037997512345678") { t.Error("audit response contains the full card number") } @@ -291,3 +295,75 @@ func TestHandlerFailsClosedWhenAuditSaveFails(t *testing.T) { t.Errorf("response body = %s, want internal_error", response.Body.String()) } } + +func TestHandlerExtractsClientIP(t *testing.T) { + tests := []struct { + name string + headerKey string + headerVal string + remoteAddr string + wantIP string + }{ + { + name: "prefers CF-Connecting-IP", + headerKey: "CF-Connecting-IP", + headerVal: "203.0.113.88", + remoteAddr: "10.0.0.1:1234", + wantIP: "203.0.113.88", + }, + { + name: "prefers X-Forwarded-For over RemoteAddr", + headerKey: "X-Forwarded-For", + headerVal: "198.51.100.77, 10.0.0.2", + remoteAddr: "10.0.0.1:1234", + wantIP: "198.51.100.77", + }, + { + name: "uses RemoteAddr host without port", + remoteAddr: "192.168.10.5:9999", + wantIP: "192.168.10.5", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + audits := &fakeAuditSaver{} + handler := NewHandler( + &fakeConverter{ + result: service.ConversionResult{ + IBAN: "IR820540102680020817909002", + UpstreamHTTPStatus: http.StatusOK, + }, + }, + audits, + slog.New(slog.NewTextHandler(io.Discard, nil)), + ) + + req := httptest.NewRequest( + http.MethodPost, + "/api/v1/card-to-iban", + strings.NewReader(`{"card":"6037997512345678"}`), + ) + if tt.headerKey != "" { + req.Header.Set(tt.headerKey, tt.headerVal) + } + req.RemoteAddr = tt.remoteAddr + + rec := httptest.NewRecorder() + handler.Convert(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + + if len(audits.records) != 1 { + t.Fatalf("audit records count = %d, want 1", len(audits.records)) + } + + recIP := audits.records[0].ClientIP + if recIP == nil || *recIP != tt.wantIP { + t.Errorf("ClientIP = %v, want %q", recIP, tt.wantIP) + } + }) + } +}