fear(client): extract client's IP and store in database and structured logs
This commit is contained in:
63
backend/internal/httpapi/client_ip.go
Normal file
63
backend/internal/httpapi/client_ip.go
Normal file
@@ -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()
|
||||
}
|
||||
143
backend/internal/httpapi/client_ip_test.go
Normal file
143
backend/internal/httpapi/client_ip_test.go
Normal 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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user