fix(gateway): forward the real client IP to the backend on every call
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m58s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m58s
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).
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user