server: trusted_proxies – X-Forwarded-For für Rate-Limit hinter Reverse-Proxy

This commit is contained in:
OpenClaw Dev 2026-11-01 04:09:09 +01:00
parent 97646da970
commit 126f4be2b9
6 changed files with 101 additions and 7 deletions

View file

@ -75,7 +75,7 @@ type Tools struct {
}
type Config struct {
Listen string `yaml:"listen" json:"listen"`
Listen string `yaml:"listen" json:"listen"`
// AuthToken is an optional static admin token (sees all sessions). Users log in via /api/auth.
AuthToken string `yaml:"auth_token" json:"auth_token"`
// AllowRegistration enables self-service sign-up (default true).
@ -84,6 +84,8 @@ type Config struct {
TokenTTLHours int `yaml:"token_ttl_hours" json:"token_ttl_hours"`
// AllowedOrigins for browser WebSocket/CORS. Empty = same host only. "*" must be set explicitly.
AllowedOrigins []string `yaml:"allowed_origins" json:"allowed_origins"`
// TrustedProxies (IPs/CIDRs) whose X-Forwarded-For is used for rate limiting. Empty = loopback only.
TrustedProxies []string `yaml:"trusted_proxies" json:"trusted_proxies"`
DataDir string `yaml:"data_dir" json:"data_dir"`
Workspace string `yaml:"workspace" json:"workspace"`
LLM LLM `yaml:"llm" json:"llm"`

View file

@ -103,14 +103,71 @@ func (l *limiter) Reset(key string) {
delete(l.hits, key)
}
func clientIP(r *http.Request) string {
// clientIP returns the peer address. If the peer is a trusted proxy, the
// right-most X-Forwarded-For entry that is not itself a trusted proxy is used.
func (s *Server) clientIP(r *http.Request) string {
h, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
h = r.RemoteAddr
}
if !s.isTrustedProxy(h) {
return h
}
var parts []string
for _, v := range r.Header.Values("X-Forwarded-For") {
for _, p := range strings.Split(v, ",") {
if p = strings.TrimSpace(p); p != "" {
parts = append(parts, p)
}
}
}
for i := len(parts) - 1; i >= 0; i-- {
if net.ParseIP(parts[i]) == nil {
break
}
if !s.isTrustedProxy(parts[i]) {
return parts[i]
}
}
return h
}
func (s *Server) isTrustedProxy(ip string) bool {
p := net.ParseIP(ip)
if p == nil {
return false
}
if len(s.TrustedProxies) == 0 {
return p.IsLoopback()
}
for _, c := range s.TrustedProxies {
if c.Contains(p) {
return true
}
}
return false
}
// ParseCIDRs turns IPs or CIDRs into networks (bare IPs become /32 or /128).
func ParseCIDRs(in []string) ([]*net.IPNet, error) {
var out []*net.IPNet
for _, v := range in {
if !strings.Contains(v, "/") {
if ip := net.ParseIP(v); ip != nil && ip.To4() != nil {
v += "/32"
} else {
v += "/128"
}
}
_, n, err := net.ParseCIDR(v)
if err != nil {
return nil, err
}
out = append(out, n)
}
return out, nil
}
func (s *Server) initAuth() {
s.authOnce.Do(func() {
if s.limiter == nil {
@ -194,7 +251,7 @@ func (s *Server) register(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusForbidden, "registration disabled")
return
}
ip := clientIP(r)
ip := s.clientIP(r)
if s.limiter.Blocked(ip) {
writeErr(w, http.StatusTooManyRequests, "too many attempts, try again later")
return
@ -232,7 +289,7 @@ func (s *Server) register(w http.ResponseWriter, r *http.Request) {
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
s.initAuth()
ip := clientIP(r)
ip := s.clientIP(r)
if s.limiter.Blocked(ip) {
writeErr(w, http.StatusTooManyRequests, "too many attempts, try again later")
return

View file

@ -0,0 +1,26 @@
package server
import (
"net/http/httptest"
"testing"
)
func TestClientIPTrustedProxy(t *testing.T) {
s := &Server{}
r := httptest.NewRequest("GET", "/", nil)
r.RemoteAddr = "127.0.0.1:1234"
r.Header.Set("X-Forwarded-For", "6.6.6.6, 1.2.3.4")
if got := s.clientIP(r); got != "1.2.3.4" {
t.Fatalf("loopback proxy: got %s", got)
}
r.RemoteAddr = "9.9.9.9:1"
if got := s.clientIP(r); got != "9.9.9.9" {
t.Fatalf("untrusted peer: got %s", got)
}
n, _ := ParseCIDRs([]string{"10.0.1.0/24"})
s.TrustedProxies = n
r.RemoteAddr = "10.0.1.6:1"
if got := s.clientIP(r); got != "1.2.3.4" {
t.Fatalf("cidr proxy: got %s", got)
}
}

View file

@ -3,9 +3,10 @@ package server
import (
"context"
"encoding/json"
"encoding/json"
"errors"
"log/slog"
"net"
"net/http"
"net/url"
"strings"
@ -38,6 +39,8 @@ type Server struct {
AllowRegistration bool
// TokenTTL is the lifetime of user tokens (default 30 days).
TokenTTL time.Duration
// TrustedProxies whose X-Forwarded-For is honoured (empty = loopback only).
TrustedProxies []*net.IPNet
authOnce sync.Once
limiter *limiter