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
+22
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/joho/godotenv"
@@ -17,6 +18,13 @@ type Config struct {
JWTSecret string
JWTAccessTTL time.Duration
JWTRefreshTTL time.Duration
// TrustedProxies lists the reverse-proxy hops (CIDRs or IPs) whose
// X-Forwarded-For header is trusted. The auth rate limiter keys on the
// client IP, so this must match the proxy in front of the app — otherwise
// every request appears to come from the proxy (one shared bucket) or a
// direct caller could forge the header. Default covers loopback and the
// Docker bridge ranges a host reverse proxy reaches the container through.
TrustedProxies []string
// Initial admin bootstrap (applied on startup if the user does not exist)
AdminUsername string
@@ -100,6 +108,18 @@ func Load() (*Config, error) {
return n
}
parseCSV := func(key, def string) []string {
raw := defaultStr(key, def)
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
parseInt64 := func(key string, def int64) int64 {
raw := os.Getenv(key)
if raw == "" {
@@ -119,6 +139,8 @@ func Load() (*Config, error) {
JWTAccessTTL: parseDuration("JWT_ACCESS_TTL", "15m"),
JWTRefreshTTL: parseDuration("JWT_REFRESH_TTL", "720h"),
TrustedProxies: parseCSV("TRUSTED_PROXIES", "127.0.0.1/32,::1/128,172.16.0.0/12"),
AdminUsername: defaultStr("ADMIN_USERNAME", "admin"),
AdminPassword: requireStr("ADMIN_PASSWORD"),
+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
}
+3 -1
View File
@@ -151,12 +151,14 @@ func setupSuite(t *testing.T) *harness {
aclHandler := handler.NewACLHandler(aclSvc)
auditHandler := handler.NewAuditHandler(auditSvc)
r := handler.NewRouter(
r, err := handler.NewRouter(
authMiddleware, authHandler,
fileHandler, tagHandler, categoryHandler, poolHandler,
userHandler, aclHandler, auditHandler,
"",
nil,
)
require.NoError(t, err)
srv := httptest.NewServer(r)
t.Cleanup(srv.Close)