fear(client): extract client's IP and store in database and structured logs

This commit is contained in:
2026-09-21 22:47:10 +03:30
parent 8bea266f03
commit faadf94927
4 changed files with 295 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
package httpapi
import (
"net"
"net/http"
"strings"
)
// extractClientIP extracts and validates the client's IP address from the HTTP request.
// It inspects standard reverse proxy and CDN headers in priority order before falling
// back to the remote socket address. Returns nil if no valid IP address can be found.
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 (leftmost) 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()
}

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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)
}
})
}
}