fix(apiserver): trust X-Forwarded-For only from configured proxies (bug-1948)

Cause: the router used gin.Default() without SetTrustedProxies, and gin
then trusts forwarding headers from every peer, so ClientIP() returned
whatever X-Forwarded-For a client sent: the IP whitelist and the IP bound
into JWTs could be spoofed.

Fix: new app.yaml option security.trusted_proxies (IP or CIDR list,
default empty = trust no proxy, validated at startup) passed to
SetTrustedProxies in Router(); an invalid list falls back to trusting
none. The nginx samples note that the proxy must be listed there.

Tests: TestRouter_IgnoresForwardedForByDefault,
TestRouter_HonoursForwardedForFromTrustedProxy, TestResolveTrustedProxies.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rongsong Shen 2026-10-10 08:35:13 +00:00
parent 42a402a5b4
commit c4c63f11e7
Signed by untrusted user: rshen
GPG key ID: 80533536B0EB651C
8 changed files with 164 additions and 1 deletions

View file

@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"log"
"net"
"os"
"runtime"
"strconv"
@ -56,6 +57,10 @@ type Authentication struct {
type Security struct {
HTTPS HTTPSConfig `yaml:"https"`
// TrustedProxies lists the reverse proxies (IP or CIDR) whose
// X-Forwarded-For / X-Real-IP headers set the client IP. Empty (the
// default) trusts no proxy: the client IP is the TCP peer address.
TrustedProxies []string `yaml:"trusted_proxies"`
}
type HTTPSConfig struct {
Enabled bool `yaml:"enabled"`
@ -105,6 +110,7 @@ var (
AppYamlConfig *AppConfig
AuthenticationYamlConfig *Authentication
HttpsYamlConfig *HTTPSConfig
TrustedProxiesYamlConfig []string
RateLimitYamlConfig *RateLimitConfig
WhitelistYamlConfig *WhitelistConfig
FileUploadYamlConfig *FileUploadConfig
@ -172,6 +178,11 @@ func InitConf() error {
CertFile: viper.GetString("security.https.cert_file"),
KeyFile: viper.GetString("security.https.key_file"),
}
trustedProxies, err := resolveTrustedProxies(viper.GetStringSlice("security.trusted_proxies"))
if err != nil {
return err
}
TrustedProxiesYamlConfig = trustedProxies
// parse rate limit section
RateLimitYamlConfig = &RateLimitConfig{
@ -310,6 +321,25 @@ func resolveMaxArraySize(v int) (effective int, clamped bool, err error) {
}
}
// resolveTrustedProxies validates security.trusted_proxies: every entry must
// be an IP address or a CIDR. An empty list means no proxy is trusted.
func resolveTrustedProxies(entries []string) ([]string, error) {
proxies := make([]string, 0, len(entries))
for _, e := range entries {
e = strings.TrimSpace(e)
if e == "" {
continue
}
if net.ParseIP(e) == nil {
if _, _, err := net.ParseCIDR(e); err != nil {
return nil, fmt.Errorf("invalid security.trusted_proxies entry %q: must be an IP address or CIDR", e)
}
}
proxies = append(proxies, e)
}
return proxies, nil
}
// resolveJWTSecret returns the JWT secret from XCE_JWT_SECRET env var if set and non-empty,
// otherwise falls back to the value from config file.
func resolveJWTSecret(configValue string) string {