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

@ -18,6 +18,14 @@ func Router() *gin.Engine {
logger.Debug("Enter Router() function")
r := gin.Default()
// Honour X-Forwarded-For / X-Real-IP only from configured reverse proxies
// (security.trusted_proxies, default none), so a client cannot choose the
// IP seen by the IP whitelist and the JWT IP binding (bug-1948).
if err := r.SetTrustedProxies(utils.TrustedProxiesYamlConfig); err != nil {
logger.Error("Invalid security.trusted_proxies, trusting no proxy", zap.Error(err))
_ = r.SetTrustedProxies(nil)
}
// check access URL whitelist
r.Use(middleware.WhitelistURLMiddleware())

View file

@ -0,0 +1,77 @@
package router
import (
"apiserver/utils"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// setRouterTestConfig installs the minimal config Router() reads, with an IP
// whitelist of 10.0.0.0/8, and restores the previous values afterwards.
func setRouterTestConfig(t *testing.T) {
t.Helper()
prevLog, prevTop := utils.Log, utils.Global_TopDir
prevWL, prevRL, prevCORS := utils.WhitelistYamlConfig, utils.RateLimitYamlConfig, utils.CORSYamlConfig
t.Cleanup(func() {
utils.Log, utils.Global_TopDir = prevLog, prevTop
utils.WhitelistYamlConfig, utils.RateLimitYamlConfig, utils.CORSYamlConfig = prevWL, prevRL, prevCORS
})
gin.SetMode(gin.TestMode)
utils.Log = zap.NewNop()
utils.Global_TopDir = t.TempDir()
utils.WhitelistYamlConfig = &utils.WhitelistConfig{Whitelist: []string{"10.0.0.0/8"}}
utils.RateLimitYamlConfig = &utils.RateLimitConfig{
Global: utils.EndpointLimit{Rate: 1000, Burst: 2000, Window: "second"},
Endpoints: map[string]utils.EndpointLimit{},
}
utils.CORSYamlConfig = nil
}
func pingFrom(r *gin.Engine, remoteAddr, forwardedFor string) int {
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
req.RemoteAddr = remoteAddr
if forwardedFor != "" {
req.Header.Set("X-Forwarded-For", forwardedFor)
}
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w.Code
}
// bug-1948: with no trusted proxies configured, X-Forwarded-For must not
// change the client IP seen by the IP whitelist (or the JWT IP binding).
func TestRouter_IgnoresForwardedForByDefault(t *testing.T) {
setRouterTestConfig(t)
r := Router()
if got := pingFrom(r, "10.1.1.1:4000", ""); got != http.StatusOK {
t.Fatalf("whitelisted peer: status = %d, want 200", got)
}
if got := pingFrom(r, "192.0.2.10:4000", "10.1.1.1"); got != http.StatusForbidden {
t.Errorf("non-whitelisted peer spoofing X-Forwarded-For: status = %d, want 403", got)
}
}
// bug-1948: X-Forwarded-For is honoured only from a configured trusted proxy.
func TestRouter_HonoursForwardedForFromTrustedProxy(t *testing.T) {
setRouterTestConfig(t)
prev := utils.TrustedProxiesYamlConfig
t.Cleanup(func() { utils.TrustedProxiesYamlConfig = prev })
utils.TrustedProxiesYamlConfig = []string{"192.0.2.1"}
r := Router()
if got := pingFrom(r, "192.0.2.1:4000", "10.1.1.1"); got != http.StatusOK {
t.Errorf("trusted proxy forwarding a whitelisted client: status = %d, want 200", got)
}
if got := pingFrom(r, "192.0.2.1:4000", "198.51.100.7"); got != http.StatusForbidden {
t.Errorf("trusted proxy forwarding a non-whitelisted client: status = %d, want 403", got)
}
if got := pingFrom(r, "192.0.2.99:4000", "10.1.1.1"); got != http.StatusForbidden {
t.Errorf("untrusted peer spoofing X-Forwarded-For: status = %d, want 403", got)
}
}