server: trusted_proxies – X-Forwarded-For für Rate-Limit hinter Reverse-Proxy
This commit is contained in:
parent
97646da970
commit
126f4be2b9
6 changed files with 101 additions and 7 deletions
|
|
@ -81,7 +81,12 @@ func run(cfgPath string, log *slog.Logger) error {
|
||||||
p, _, err := pm.ForSession(ctx, sid)
|
p, _, err := pm.ForSession(ctx, sid)
|
||||||
return p, err
|
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},
|
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}
|
RunTimeout: time.Duration(cfg.LLM.TimeoutSec*cfg.Agent.MaxIterations) * time.Second}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
allow_registration: true # users sign up via POST /api/auth/register; set false after creating your account
|
||||||
token_ttl_hours: 720 # login token lifetime
|
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
|
allowed_origins: [] # browser origins allowed besides same host; "*" only if you really mean it
|
||||||
data_dir: ./data
|
data_dir: ./data
|
||||||
workspace: "" # default: <data_dir>/workspace; file tools cannot leave it
|
workspace: "" # default: <data_dir>/workspace; file tools cannot leave it
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ type Tools struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Config 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 is an optional static admin token (sees all sessions). Users log in via /api/auth.
|
||||||
AuthToken string `yaml:"auth_token" json:"auth_token"`
|
AuthToken string `yaml:"auth_token" json:"auth_token"`
|
||||||
// AllowRegistration enables self-service sign-up (default true).
|
// 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"`
|
TokenTTLHours int `yaml:"token_ttl_hours" json:"token_ttl_hours"`
|
||||||
// AllowedOrigins for browser WebSocket/CORS. Empty = same host only. "*" must be set explicitly.
|
// AllowedOrigins for browser WebSocket/CORS. Empty = same host only. "*" must be set explicitly.
|
||||||
AllowedOrigins []string `yaml:"allowed_origins" json:"allowed_origins"`
|
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"`
|
DataDir string `yaml:"data_dir" json:"data_dir"`
|
||||||
Workspace string `yaml:"workspace" json:"workspace"`
|
Workspace string `yaml:"workspace" json:"workspace"`
|
||||||
LLM LLM `yaml:"llm" json:"llm"`
|
LLM LLM `yaml:"llm" json:"llm"`
|
||||||
|
|
|
||||||
|
|
@ -103,14 +103,71 @@ func (l *limiter) Reset(key string) {
|
||||||
delete(l.hits, key)
|
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)
|
h, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
if err != nil {
|
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
|
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() {
|
func (s *Server) initAuth() {
|
||||||
s.authOnce.Do(func() {
|
s.authOnce.Do(func() {
|
||||||
if s.limiter == nil {
|
if s.limiter == nil {
|
||||||
|
|
@ -194,7 +251,7 @@ func (s *Server) register(w http.ResponseWriter, r *http.Request) {
|
||||||
writeErr(w, http.StatusForbidden, "registration disabled")
|
writeErr(w, http.StatusForbidden, "registration disabled")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ip := clientIP(r)
|
ip := s.clientIP(r)
|
||||||
if s.limiter.Blocked(ip) {
|
if s.limiter.Blocked(ip) {
|
||||||
writeErr(w, http.StatusTooManyRequests, "too many attempts, try again later")
|
writeErr(w, http.StatusTooManyRequests, "too many attempts, try again later")
|
||||||
return
|
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) {
|
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||||
s.initAuth()
|
s.initAuth()
|
||||||
ip := clientIP(r)
|
ip := s.clientIP(r)
|
||||||
if s.limiter.Blocked(ip) {
|
if s.limiter.Blocked(ip) {
|
||||||
writeErr(w, http.StatusTooManyRequests, "too many attempts, try again later")
|
writeErr(w, http.StatusTooManyRequests, "too many attempts, try again later")
|
||||||
return
|
return
|
||||||
|
|
|
||||||
26
internal/server/clientip_test.go
Normal file
26
internal/server/clientip_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,9 +3,10 @@ package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -38,6 +39,8 @@ type Server struct {
|
||||||
AllowRegistration bool
|
AllowRegistration bool
|
||||||
// TokenTTL is the lifetime of user tokens (default 30 days).
|
// TokenTTL is the lifetime of user tokens (default 30 days).
|
||||||
TokenTTL time.Duration
|
TokenTTL time.Duration
|
||||||
|
// TrustedProxies whose X-Forwarded-For is honoured (empty = loopback only).
|
||||||
|
TrustedProxies []*net.IPNet
|
||||||
|
|
||||||
authOnce sync.Once
|
authOnce sync.Once
|
||||||
limiter *limiter
|
limiter *limiter
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue