fix(web): harden trusted proxy client IP parsing

This commit is contained in:
lc6464
2026-06-10 12:30:00 +08:00
committed by Guoguo
parent 52ab6c4694
commit 017601354b
3 changed files with 95 additions and 11 deletions
+1
View File
@@ -156,6 +156,7 @@ When public access is enabled:
- optional `allowed_cidrs` can restrict which client IP ranges may connect
- `allow_localhost_bypass` defaults to `true`; set it to `false` when same-host proxies or tunnels should not bypass `allowed_cidrs`
- optional `trusted_proxy_cidrs` can trust specific reverse proxies to supply the original client IP through headers such as `X-Forwarded-For`
- trusted proxy deployments should overwrite or sanitize forwarding headers such as `X-Forwarded-For` and `X-Real-IP` instead of passing through user-supplied values
- the gateway host is overridden so remote clients can still use the launcher-managed proxy paths
## Build And Run
+25 -9
View File
@@ -41,9 +41,7 @@ func IPAllowlist(cfg IPAllowlistConfig, next http.Handler) (http.Handler, error)
ip := peerIP
if containsIP(trustedProxyNets, peerIP) {
if forwardedIP := clientIPFromXForwardedFor(r.Header.Get("X-Forwarded-For")); forwardedIP != nil {
ip = forwardedIP
}
ip = clientIPFromXForwardedFor(r.Header.Get("X-Forwarded-For"), trustedProxyNets, peerIP)
}
if cfg.AllowLocalhostBypass && ip.IsLoopback() {
@@ -88,16 +86,34 @@ func clientIPFromRemoteAddr(remoteAddr string) net.IP {
return net.ParseIP(host)
}
func clientIPFromXForwardedFor(header string) net.IP {
first, _, _ := strings.Cut(header, ",")
first = strings.Trim(strings.TrimSpace(first), `"`)
if first == "" {
func clientIPFromXForwardedFor(header string, trustedProxyNets []*net.IPNet, fallback net.IP) net.IP {
parts := strings.Split(header, ",")
ips := make([]net.IP, 0, len(parts))
for _, part := range parts {
if ip := parseIPToken(part); ip != nil {
ips = append(ips, ip)
}
}
if len(ips) == 0 {
return fallback
}
for i := len(ips) - 1; i >= 0; i-- {
if !containsIP(trustedProxyNets, ips[i]) {
return ips[i]
}
}
return ips[0]
}
func parseIPToken(raw string) net.IP {
token := strings.Trim(strings.TrimSpace(raw), `"`)
if token == "" {
return nil
}
if ip := net.ParseIP(first); ip != nil {
if ip := net.ParseIP(token); ip != nil {
return ip
}
if host, _, err := net.SplitHostPort(first); err == nil {
if host, _, err := net.SplitHostPort(token); err == nil {
return net.ParseIP(host)
}
return nil
+69 -2
View File
@@ -127,7 +127,7 @@ func TestIPAllowlist_IgnoresXForwardedForFromUntrustedPeer(t *testing.T) {
}
}
func TestIPAllowlist_UsesXForwardedForFromTrustedPeer(t *testing.T) {
func TestIPAllowlist_UsesRightmostUntrustedXForwardedForIPFromTrustedPeer(t *testing.T) {
h, err := IPAllowlist(IPAllowlistConfig{
AllowedCIDRs: []string{"192.168.1.0/24"},
TrustedProxyCIDRs: []string{"10.0.0.0/8"},
@@ -141,7 +141,7 @@ func TestIPAllowlist_UsesXForwardedForFromTrustedPeer(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.0.0.8:1234"
req.Header.Set("X-Forwarded-For", "192.168.1.88, 203.0.113.5")
req.Header.Set("X-Forwarded-For", "203.0.113.5, 192.168.1.88, 10.0.0.9")
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
@@ -149,6 +149,73 @@ func TestIPAllowlist_UsesXForwardedForFromTrustedPeer(t *testing.T) {
}
}
func TestIPAllowlist_UsesRightmostUntrustedIPFromTrustedProxyChain(t *testing.T) {
h, err := IPAllowlist(IPAllowlistConfig{
AllowedCIDRs: []string{"192.168.1.0/24"},
TrustedProxyCIDRs: []string{"10.0.0.0/8"},
}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
if err != nil {
t.Fatalf("IPAllowlist() error = %v", err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.0.0.8:1234"
req.Header.Set("X-Forwarded-For", "192.168.1.88, 10.0.0.9, 10.0.0.10")
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
}
func TestIPAllowlist_DoesNotTrustSpoofedLeftmostLoopbackInXForwardedFor(t *testing.T) {
h, err := IPAllowlist(IPAllowlistConfig{
AllowedCIDRs: []string{"192.168.1.0/24"},
AllowLocalhostBypass: true,
TrustedProxyCIDRs: []string{"10.0.0.0/8"},
}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
if err != nil {
t.Fatalf("IPAllowlist() error = %v", err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.0.0.8:1234"
req.Header.Set("X-Forwarded-For", "127.0.0.1, 203.0.113.5")
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
}
}
func TestIPAllowlist_AllTrustedProxyChainFallsBackToLeftmostIP(t *testing.T) {
h, err := IPAllowlist(IPAllowlistConfig{
AllowedCIDRs: []string{"192.168.1.0/24"},
TrustedProxyCIDRs: []string{"10.0.0.0/8"},
}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
if err != nil {
t.Fatalf("IPAllowlist() error = %v", err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.0.0.8:1234"
req.Header.Set("X-Forwarded-For", "10.0.0.9, 10.0.0.10")
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
}
}
func TestIPAllowlist_InvalidCIDR(t *testing.T) {
_, err := IPAllowlist(IPAllowlistConfig{
AllowedCIDRs: []string{"bad-cidr"},