From ff55d5de83298c1a806d94f349beb536b53ace80 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 14 Jul 2026 11:23:32 +0200 Subject: [PATCH] fix(gateway): forward the real client IP to the backend on every call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend recorded 172.19.0.9 — the gateway's own docker connection address — as the client IP for all users: the account's last-login IP shown in the admin console, and it never reached the backend access log. The gateway forwarded the client IP as X-Forwarded-For only on chat/feedback calls; every other backend call (including the profile fetch that stamps last_login_ip) sent none, so the backend fell back to the peer address. Carry the client IP on the request context (WithClientIP, mirroring WithPlatform) and set it once per request in the Connect edge, so the backend client injects X-Forwarded-For on every downstream REST call. Also add the resolved client IP to the backend access log. Test: WithClientIP rides a non-chat call (Profile) as X-Forwarded-For, and is absent when no IP is set. Docs updated (ARCHITECTURE gateway↔backend + edge). --- backend/internal/telemetry/middleware.go | 3 ++ docs/ARCHITECTURE.md | 8 +++- gateway/internal/backendclient/client.go | 27 ++++++++++++ gateway/internal/backendclient/client_test.go | 41 +++++++++++++++++++ gateway/internal/connectsrv/server.go | 4 ++ 5 files changed, 81 insertions(+), 2 deletions(-) diff --git a/backend/internal/telemetry/middleware.go b/backend/internal/telemetry/middleware.go index 4485a5a..4a59b74 100644 --- a/backend/internal/telemetry/middleware.go +++ b/backend/internal/telemetry/middleware.go @@ -56,6 +56,9 @@ func Middleware(logger *zap.Logger) gin.HandlerFunc { zap.String("path", route), zap.Int("status", status), zap.Duration("latency", elapsed), + // The gateway forwards the real caller as X-Forwarded-For (and Caddy does for /_gm), which + // gin resolves here — so the access log carries the client IP, not the gateway's connection. + zap.String("client_ip", c.ClientIP()), } fields = append(fields, TraceFieldsFromContext(ctx)...) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 25de66e..ff21a40 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -151,7 +151,10 @@ dropped). Horizontal scaling is explicit future work. and GCG are unaffected** (they stay decoded concrete characters, §9.1). - **gateway ↔ backend (sync)**: plain HTTP REST/JSON. The gateway injects `X-User-ID` (and the session's trusted `X-Platform`, §3) for authenticated - requests; `backend` never re-derives identity or platform from the body. Because every sync call targets the one backend host, the + requests, plus the caller's real IP as **`X-Forwarded-For`** on **every** call + (carried on the request context), so the backend records the real caller — the + account's last-login IP, chat/feedback moderation, the access log — rather than + the gateway's own connection address; `backend` never re-derives identity or platform from the body. Because every sync call targets the one backend host, the gateway's REST client widens its keep-alive pool well past the stdlib default of 2 idle connections per host; otherwise the per-request connection churn exhausts ephemeral ports and burns gateway CPU under load (see @@ -1496,7 +1499,8 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`): forwards the domain to `scrabble:80`, so the in-compose caddy serves plain HTTP (`CADDY_SITE_ADDRESS=:80`). The in-compose caddy **trusts X-Forwarded-For from private-range upstreams** (`trusted_proxies private_ranges`), so the real client IP — - used for chat-moderation logging and the gateway's per-IP rate limiting — survives the + used for the gateway's per-IP rate limiting and, forwarded on to the backend, the account's + last-login IP, chat/feedback moderation and the access log — survives the host-caddy hop; in prod (no host caddy) public clients are untrusted and Caddy uses the real peer, so the single config is correct and spoof-safe in both contours. The **bot-link mTLS material** (a private CA + gateway/bot leaves, CN=`gateway`) is diff --git a/gateway/internal/backendclient/client.go b/gateway/internal/backendclient/client.go index 9da6bfa..d66464e 100644 --- a/gateway/internal/backendclient/client.go +++ b/gateway/internal/backendclient/client.go @@ -139,6 +139,26 @@ func platformFromContext(ctx context.Context) string { return p } +// clientIPCtxKey types the request-context slot the originating client IP rides in. +type clientIPCtxKey struct{} + +// WithClientIP returns a copy of ctx carrying the originating client IP that do injects as +// X-Forwarded-For on every downstream backend request — so the backend records the real caller +// (the account's last-login IP, chat/feedback moderation, the access log) rather than the gateway's +// own connection. An empty IP leaves ctx unchanged, so no header is sent. +func WithClientIP(ctx context.Context, ip string) context.Context { + if ip == "" { + return ctx + } + return context.WithValue(ctx, clientIPCtxKey{}, ip) +} + +// clientIPFromContext returns the client IP stored by WithClientIP, or an empty string when none. +func clientIPFromContext(ctx context.Context) string { + ip, _ := ctx.Value(clientIPCtxKey{}).(string) + return ip +} + // do performs one REST call. userID, when non-empty, is forwarded as X-User-ID; // clientIP, when non-empty, as X-Forwarded-For (for chat moderation); the trusted // platform carried on ctx (see WithPlatform), when present, as X-Platform. A non-2xx @@ -160,6 +180,13 @@ func (c *Client) do(ctx context.Context, method, path, userID, clientIP string, if userID != "" { req.Header.Set("X-User-ID", userID) } + // The client IP rides X-Forwarded-For so the backend records the real caller. It comes from the + // explicit param (chat/feedback) or, for every other call, the request context (WithClientIP, set + // once per request in the Connect edge) — so the backend never falls back to the gateway's own + // connection address. + if clientIP == "" { + clientIP = clientIPFromContext(ctx) + } if clientIP != "" { req.Header.Set("X-Forwarded-For", clientIP) } diff --git a/gateway/internal/backendclient/client_test.go b/gateway/internal/backendclient/client_test.go index 68ea42b..e419303 100644 --- a/gateway/internal/backendclient/client_test.go +++ b/gateway/internal/backendclient/client_test.go @@ -122,3 +122,44 @@ func TestXPlatformInjection(t *testing.T) { } }) } + +// TestXForwardedForInjection verifies the client IP carried on the context (WithClientIP) rides every +// backend call as X-Forwarded-For — not just chat/feedback, which pass it explicitly — so the backend +// records the real caller (e.g. the account's last-login IP on the profile fetch) rather than the +// gateway's own connection. Absent when no client IP is set. +func TestXForwardedForInjection(t *testing.T) { + var gotXFF string + var hadHeader bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotXFF = r.Header.Get("X-Forwarded-For") + _, hadHeader = r.Header["X-Forwarded-For"] + _, _ = w.Write([]byte(`{}`)) + })) + defer srv.Close() + + c, err := backendclient.New(srv.URL, "localhost:9090", 2*time.Second) + if err != nil { + t.Fatalf("backendclient: %v", err) + } + defer func() { _ = c.Close() }() + + t.Run("client IP on ctx rides a non-chat call", func(t *testing.T) { + ctx := backendclient.WithClientIP(context.Background(), "203.0.113.7") + if _, err := c.Profile(ctx, "user-1"); err != nil { + t.Fatalf("Profile: %v", err) + } + if gotXFF != "203.0.113.7" { + t.Fatalf("X-Forwarded-For = %q, want 203.0.113.7", gotXFF) + } + }) + + t.Run("no client IP omits the header", func(t *testing.T) { + hadHeader = true + if _, err := c.Profile(context.Background(), "user-1"); err != nil { + t.Fatalf("Profile: %v", err) + } + if hadHeader { + t.Fatal("X-Forwarded-For must be absent when no client IP is set") + } + }) +} diff --git a/gateway/internal/connectsrv/server.go b/gateway/internal/connectsrv/server.go index b0826e6..d5b2cfd 100644 --- a/gateway/internal/connectsrv/server.go +++ b/gateway/internal/connectsrv/server.go @@ -418,6 +418,10 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut return nil, connect.NewError(connect.CodeNotFound, errUnknownMessageType(msgType)) } clientIP := peerIP(req.Peer().Addr, req.Header()) + // Carry the client IP on the context so the backend client injects it as X-Forwarded-For on every + // downstream REST call for this request — the account's last-login IP, chat/feedback moderation and + // the backend access log, not only the chat/feedback calls that pass it explicitly. + ctx = backendclient.WithClientIP(ctx, clientIP) tr := transcode.Request{Payload: req.Msg.GetPayload(), ClientIP: clientIP} if op.Auth {