From 126f4be2b916269107774ef3ddc4e078359ba52d Mon Sep 17 00:00:00 2001 From: OpenClaw Dev Date: Sun, 1 Nov 2026 04:09:09 +0100 Subject: [PATCH] =?UTF-8?q?server:=20trusted=5Fproxies=20=E2=80=93=20X-For?= =?UTF-8?q?warded-For=20f=C3=BCr=20Rate-Limit=20hinter=20Reverse-Proxy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/byteclaw/main.go | 7 +++- config.example.yaml | 1 + internal/config/config.go | 4 +- internal/server/auth.go | 65 ++++++++++++++++++++++++++++++-- internal/server/clientip_test.go | 26 +++++++++++++ internal/server/server.go | 5 ++- 6 files changed, 101 insertions(+), 7 deletions(-) create mode 100644 internal/server/clientip_test.go diff --git a/cmd/byteclaw/main.go b/cmd/byteclaw/main.go index 0a34729..23a1b40 100644 --- a/cmd/byteclaw/main.go +++ b/cmd/byteclaw/main.go @@ -81,7 +81,12 @@ func run(cfgPath string, log *slog.Logger) error { p, _, err := pm.ForSession(ctx, sid) return p, err }} - srv := &server.Server{Agent: ag, Store: st, Token: cfg.AuthToken, AllowRegistration: cfg.RegistrationAllowed(), TokenTTL: time.Duration(cfg.TokenTTLHours) * time.Hour, AllowedOrigins: cfg.AllowedOrigins, Log: log, Providers: pm, + trusted, err := server.ParseCIDRs(cfg.TrustedProxies) + if err != nil { + log.Error("invalid trusted_proxies", "err", err) + os.Exit(1) + } + srv := &server.Server{TrustedProxies: trusted, Agent: ag, Store: st, Token: cfg.AuthToken, AllowRegistration: cfg.RegistrationAllowed(), TokenTTL: time.Duration(cfg.TokenTTLHours) * time.Hour, AllowedOrigins: cfg.AllowedOrigins, Log: log, Providers: pm, Info: server.Info{Version: version, Provider: cfg.LLM.Provider, Model: cfg.LLM.Model, BaseURL: cfg.LLM.BaseURL}, RunTimeout: time.Duration(cfg.LLM.TimeoutSec*cfg.Agent.MaxIterations) * time.Second} diff --git a/config.example.yaml b/config.example.yaml index d179374..e3eb03d 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -5,6 +5,7 @@ listen: 127.0.0.1:8787 # bind to 0.0.0.0 only behind TLS (reverse proxy auth_token: "" # optional static admin token (>= 16 chars, sees all sessions). Generate: openssl rand -hex 32 allow_registration: true # users sign up via POST /api/auth/register; set false after creating your account token_ttl_hours: 720 # login token lifetime +trusted_proxies: [] # reverse proxy IPs/CIDRs whose X-Forwarded-For is trusted (empty = loopback only) allowed_origins: [] # browser origins allowed besides same host; "*" only if you really mean it data_dir: ./data workspace: "" # default: /workspace; file tools cannot leave it diff --git a/internal/config/config.go b/internal/config/config.go index 794b94a..abbddee 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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"` diff --git a/internal/server/auth.go b/internal/server/auth.go index bd481cc..c2521da 100644 --- a/internal/server/auth.go +++ b/internal/server/auth.go @@ -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 diff --git a/internal/server/clientip_test.go b/internal/server/clientip_test.go new file mode 100644 index 0000000..ae6379e --- /dev/null +++ b/internal/server/clientip_test.go @@ -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) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 5e2afb9..3868a19 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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