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:
@@ -114,12 +114,17 @@ func main() {
|
|||||||
aclHandler := handler.NewACLHandler(aclSvc)
|
aclHandler := handler.NewACLHandler(aclSvc)
|
||||||
auditHandler := handler.NewAuditHandler(auditSvc)
|
auditHandler := handler.NewAuditHandler(auditSvc)
|
||||||
|
|
||||||
r := handler.NewRouter(
|
r, err := handler.NewRouter(
|
||||||
authMiddleware, authHandler,
|
authMiddleware, authHandler,
|
||||||
fileHandler, tagHandler, categoryHandler, poolHandler,
|
fileHandler, tagHandler, categoryHandler, poolHandler,
|
||||||
userHandler, aclHandler, auditHandler,
|
userHandler, aclHandler, auditHandler,
|
||||||
cfg.StaticDir,
|
cfg.StaticDir,
|
||||||
|
cfg.TrustedProxies,
|
||||||
)
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("building router", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
// ReadHeaderTimeout bounds slow-header (Slowloris) attacks; body read/write
|
// ReadHeaderTimeout bounds slow-header (Slowloris) attacks; body read/write
|
||||||
// are left unbounded so large file uploads and downloads can stream.
|
// are left unbounded so large file uploads and downloads can stream.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
@@ -17,6 +18,13 @@ type Config struct {
|
|||||||
JWTSecret string
|
JWTSecret string
|
||||||
JWTAccessTTL time.Duration
|
JWTAccessTTL time.Duration
|
||||||
JWTRefreshTTL 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)
|
// Initial admin bootstrap (applied on startup if the user does not exist)
|
||||||
AdminUsername string
|
AdminUsername string
|
||||||
@@ -100,6 +108,18 @@ func Load() (*Config, error) {
|
|||||||
return n
|
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 {
|
parseInt64 := func(key string, def int64) int64 {
|
||||||
raw := os.Getenv(key)
|
raw := os.Getenv(key)
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
@@ -119,6 +139,8 @@ func Load() (*Config, error) {
|
|||||||
JWTAccessTTL: parseDuration("JWT_ACCESS_TTL", "15m"),
|
JWTAccessTTL: parseDuration("JWT_ACCESS_TTL", "15m"),
|
||||||
JWTRefreshTTL: parseDuration("JWT_REFRESH_TTL", "720h"),
|
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"),
|
AdminUsername: defaultStr("ADMIN_USERNAME", "admin"),
|
||||||
AdminPassword: requireStr("ADMIN_PASSWORD"),
|
AdminPassword: requireStr("ADMIN_PASSWORD"),
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -32,10 +33,20 @@ func NewRouter(
|
|||||||
aclHandler *ACLHandler,
|
aclHandler *ACLHandler,
|
||||||
auditHandler *AuditHandler,
|
auditHandler *AuditHandler,
|
||||||
staticDir string,
|
staticDir string,
|
||||||
) *gin.Engine {
|
trustedProxies []string,
|
||||||
|
) (*gin.Engine, error) {
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.Use(gin.Logger(), gin.Recovery(), securityHeaders())
|
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.
|
// Health check — no auth required.
|
||||||
r.GET("/health", func(c *gin.Context) {
|
r.GET("/health", func(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
@@ -189,5 +200,5 @@ func NewRouter(
|
|||||||
r.NoRoute(spaHandler(staticDir))
|
r.NoRoute(spaHandler(staticDir))
|
||||||
}
|
}
|
||||||
|
|
||||||
return r
|
return r, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,12 +151,14 @@ func setupSuite(t *testing.T) *harness {
|
|||||||
aclHandler := handler.NewACLHandler(aclSvc)
|
aclHandler := handler.NewACLHandler(aclSvc)
|
||||||
auditHandler := handler.NewAuditHandler(auditSvc)
|
auditHandler := handler.NewAuditHandler(auditSvc)
|
||||||
|
|
||||||
r := handler.NewRouter(
|
r, err := handler.NewRouter(
|
||||||
authMiddleware, authHandler,
|
authMiddleware, authHandler,
|
||||||
fileHandler, tagHandler, categoryHandler, poolHandler,
|
fileHandler, tagHandler, categoryHandler, poolHandler,
|
||||||
userHandler, aclHandler, auditHandler,
|
userHandler, aclHandler, auditHandler,
|
||||||
"",
|
"",
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
srv := httptest.NewServer(r)
|
srv := httptest.NewServer(r)
|
||||||
t.Cleanup(srv.Close)
|
t.Cleanup(srv.Close)
|
||||||
|
|||||||
Reference in New Issue
Block a user