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) {
|
func (h *Handler) Convert(w http.ResponseWriter, r *http.Request) {
|
||||||
startedAt := h.now()
|
startedAt := h.now()
|
||||||
requestID := h.newRequestID()
|
requestID := h.newRequestID()
|
||||||
|
clientIP := extractClientIP(r)
|
||||||
|
|
||||||
w.Header().Set("X-Request-ID", requestID)
|
w.Header().Set("X-Request-ID", requestID)
|
||||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
@@ -89,6 +90,7 @@ func (h *Handler) Convert(w http.ResponseWriter, r *http.Request) {
|
|||||||
RequestID: requestID,
|
RequestID: requestID,
|
||||||
CreatedAt: startedAt,
|
CreatedAt: startedAt,
|
||||||
Duration: duration,
|
Duration: duration,
|
||||||
|
ClientIP: clientIP,
|
||||||
Status: outcome.auditStatus,
|
Status: outcome.auditStatus,
|
||||||
MaskedCard: card.Mask(cardNumber),
|
MaskedCard: card.Mask(cardNumber),
|
||||||
ResponseJSON: audit.SanitizeResponse(
|
ResponseJSON: audit.SanitizeResponse(
|
||||||
@@ -102,11 +104,17 @@ func (h *Handler) Convert(w http.ResponseWriter, r *http.Request) {
|
|||||||
record.UpstreamHTTPStatus = &upstreamStatus
|
record.UpstreamHTTPStatus = &upstreamStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var clientIPValue string
|
||||||
|
if clientIP != nil {
|
||||||
|
clientIPValue = *clientIP
|
||||||
|
}
|
||||||
|
|
||||||
if err := h.audits.Save(r.Context(), record); err != nil {
|
if err := h.audits.Save(r.Context(), record); err != nil {
|
||||||
h.logger.ErrorContext(
|
h.logger.ErrorContext(
|
||||||
r.Context(),
|
r.Context(),
|
||||||
"request audit failed",
|
"request audit failed",
|
||||||
"request_id", requestID,
|
"request_id", requestID,
|
||||||
|
"client_ip", clientIPValue,
|
||||||
"route", "/api/v1/card-to-iban",
|
"route", "/api/v1/card-to-iban",
|
||||||
"http_status", http.StatusInternalServerError,
|
"http_status", http.StatusInternalServerError,
|
||||||
"audit_status", audit.StatusInternalError,
|
"audit_status", audit.StatusInternalError,
|
||||||
@@ -128,6 +136,7 @@ func (h *Handler) Convert(w http.ResponseWriter, r *http.Request) {
|
|||||||
r.Context(),
|
r.Context(),
|
||||||
"request completed",
|
"request completed",
|
||||||
"request_id", requestID,
|
"request_id", requestID,
|
||||||
|
"client_ip", clientIPValue,
|
||||||
"route", "/api/v1/card-to-iban",
|
"route", "/api/v1/card-to-iban",
|
||||||
"http_status", outcome.httpStatus,
|
"http_status", outcome.httpStatus,
|
||||||
"audit_status", outcome.auditStatus,
|
"audit_status", outcome.auditStatus,
|
||||||
|
|||||||
@@ -248,6 +248,10 @@ func TestHandlerConvert(t *testing.T) {
|
|||||||
t.Errorf("masked card = %q, want %q", record.MaskedCard, tt.wantMaskedCard)
|
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") {
|
if strings.Contains(string(record.ResponseJSON), "6037997512345678") {
|
||||||
t.Error("audit response contains the full card number")
|
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())
|
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