feat(backend): trust reverse-proxy X-Forwarded-For for the client IP

The auth rate limiter keys on c.ClientIP(), but the router was built with
gin.New() and never called SetTrustedProxies — so Gin trusted all proxies by
default. Behind a host reverse proxy that meant the limiter either bucketed
every request under the proxy's IP, or (with the port reachable directly) could
be bypassed by a forged X-Forwarded-For.

NewRouter now takes a trusted-proxy list and configures SetTrustedProxies,
returning an error on an invalid list so misconfiguration fails fast at startup.
The list comes from a new TRUSTED_PROXIES config (CSV of CIDRs/IPs), defaulting
to loopback plus the Docker bridge ranges a host proxy reaches the container
through.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 14:51:44 +03:00
parent 88e07f0723
commit 99668ec0d8
4 changed files with 44 additions and 4 deletions
+13 -2
View File
@@ -1,6 +1,7 @@
package handler
import (
"fmt"
"net/http"
"time"
@@ -32,10 +33,20 @@ func NewRouter(
aclHandler *ACLHandler,
auditHandler *AuditHandler,
staticDir string,
) *gin.Engine {
trustedProxies []string,
) (*gin.Engine, error) {
r := gin.New()
r.Use(gin.Logger(), gin.Recovery(), securityHeaders())
// Behind a reverse proxy the client's real IP arrives in X-Forwarded-For.
// Trust only the proxy hop(s) so c.ClientIP() — used by the auth rate
// limiter — reflects the real client and can't be spoofed by a forged
// header from a direct caller. An empty list trusts no proxy (ClientIP is
// the immediate peer).
if err := r.SetTrustedProxies(trustedProxies); err != nil {
return nil, fmt.Errorf("configure trusted proxies: %w", err)
}
// Health check — no auth required.
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
@@ -189,5 +200,5 @@ func NewRouter(
r.NoRoute(spaHandler(staticDir))
}
return r
return r, nil
}