diff --git a/README.md b/README.md index 8cd9688..0c8c8c2 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,21 @@ All configuration is YAML. - `listen_addr`: address for the HTTP server (required). - Default: `:8080` +- `read_header_timeout`: maximum time for reading request headers. + - Must be `> 0` + - Default: `5s` +- `read_timeout`: maximum time for reading the full request. + - Must be `> 0` + - Default: `30s` +- `write_timeout`: maximum time allowed for writing the response. + - Must be `> 0` + - Default: `30s` +- `idle_timeout`: maximum keep-alive idle time between requests. + - Must be `> 0` + - Default: `120s` +- `max_header_bytes`: max HTTP header size in bytes. + - Must be `> 0` + - Default: `1048576` (1 MiB) ### `limits` @@ -64,7 +79,11 @@ All configuration is YAML. - Add trusted IPs/CIDRs for LAN/proxy clients (for example `192.168.1.0/24`) - Empty list means no allowlist restriction - `trust_proxy_headers`: if `true`, client IP extraction honors `X-Forwarded-For` then `X-Real-IP`. + - Only used when source IP matches `trusted_proxy_ips`. - Keep `false` unless behind a trusted reverse proxy that sets these headers. +- `trusted_proxy_ips`: list of reverse-proxy IPs/CIDRs permitted to supply forwarded headers. + - Required when `trust_proxy_headers: true` + - Examples: `127.0.0.1`, `10.0.0.0/8` - `rate_limit.enabled`: enable per-IP token-bucket rate limiting on write route. - Default: `true` - `rate_limit.requests_per_minute`: steady refill rate per client IP. @@ -103,3 +122,10 @@ If exposed beyond a trusted LAN, place a reverse proxy in front (Nginx, Caddy, T - Monitoring and log retention suitable for abuse triage. Recommended baseline: run behind a reverse proxy, keep low TTL and size limits, and use `allowed_ips` for write access whenever possible. + +## Storage Model + +Scratchbox storage is designed for a single process instance per `storage.data_dir`. + +- Do not run multiple scratchbox processes against the same data directory. +- Metadata/index writes are process-local and are not coordinated with cross-process locking. diff --git a/cmd/scratchbox/server.go b/cmd/scratchbox/server.go index cd3c5f8..b8701ef 100644 --- a/cmd/scratchbox/server.go +++ b/cmd/scratchbox/server.go @@ -59,8 +59,13 @@ func run(configPath string) error { go cleanupWorker.Start(ctx) httpServer := &http.Server{ - Addr: cfg.Server.ListenAddr, - Handler: server.Routes(), + Addr: cfg.Server.ListenAddr, + Handler: server.Routes(), + ReadHeaderTimeout: cfg.Server.ReadHeaderTimeoutDur, + ReadTimeout: cfg.Server.ReadTimeoutDur, + WriteTimeout: cfg.Server.WriteTimeoutDur, + IdleTimeout: cfg.Server.IdleTimeoutDur, + MaxHeaderBytes: cfg.Server.MaxHeaderBytes, } go func() { diff --git a/internal/config/config.go b/internal/config/config.go index 1f8bb5f..2b43ddf 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,6 +15,11 @@ import ( const ( defaultListenAddr = ":8080" + defaultReadHeaderTimeout = "5s" + defaultReadTimeout = "30s" + defaultWriteTimeout = "30s" + defaultIdleTimeout = "120s" + defaultMaxHeaderBytes = 1 << 20 defaultMaxUploadSize = "100MiB" defaultTTL = "1h" defaultRawCacheMaxSize = "200MiB" @@ -36,7 +41,16 @@ type Config struct { } type ServerConfig struct { - ListenAddr string `yaml:"listen_addr" comment:"Address to bind the HTTP server to."` + ListenAddr string `yaml:"listen_addr" comment:"Address to bind the HTTP server to."` + ReadHeaderTimeout string `yaml:"read_header_timeout" comment:"Maximum duration for reading request headers (for slowloris protection). Must be > 0."` + ReadTimeout string `yaml:"read_timeout" comment:"Maximum duration for reading the full request, including body. Must be > 0."` + WriteTimeout string `yaml:"write_timeout" comment:"Maximum duration before timing out writes of a response. Must be > 0."` + IdleTimeout string `yaml:"idle_timeout" comment:"Maximum amount of time to wait for the next request when keep-alives are enabled. Must be > 0."` + MaxHeaderBytes int `yaml:"max_header_bytes" comment:"Maximum size of request headers in bytes. Must be > 0."` + ReadHeaderTimeoutDur time.Duration `yaml:"-"` + ReadTimeoutDur time.Duration `yaml:"-"` + WriteTimeoutDur time.Duration `yaml:"-"` + IdleTimeoutDur time.Duration `yaml:"-"` } type LimitsConfig struct { @@ -56,10 +70,12 @@ type StorageConfig struct { } type SecurityConfig struct { - AllowedIPs []string `yaml:"allowed_ips" comment:"Allowlist for POST /api/scratch as IPs or CIDRs (examples: 127.0.0.1, 192.168.1.0/24). Keep 127.0.0.1 for local access; add trusted ranges as needed. Empty means unrestricted."` - TrustProxyHeaders bool `yaml:"trust_proxy_headers" comment:"Honor X-Forwarded-For / X-Real-IP when true (trusted proxy only)."` - RateLimit RateLimitConfig `yaml:"rate_limit" comment:"Per-client token bucket on POST /api/scratch."` - AllowedPrefixes []netip.Prefix `yaml:"-"` + AllowedIPs []string `yaml:"allowed_ips" comment:"Allowlist for POST /api/scratch as IPs or CIDRs (examples: 127.0.0.1, 192.168.1.0/24). Keep 127.0.0.1 for local access; add trusted ranges as needed. Empty means unrestricted."` + TrustProxyHeaders bool `yaml:"trust_proxy_headers" comment:"Honor X-Forwarded-For / X-Real-IP only when request source IP matches security.trusted_proxy_ips."` + TrustedProxyIPs []string `yaml:"trusted_proxy_ips" comment:"IPs/CIDRs of reverse proxies allowed to supply forwarded client IP headers. Required when trust_proxy_headers is true."` + RateLimit RateLimitConfig `yaml:"rate_limit" comment:"Per-client token bucket on POST /api/scratch."` + AllowedPrefixes []netip.Prefix `yaml:"-"` + TrustedProxyPrefixes []netip.Prefix `yaml:"-"` } type RateLimitConfig struct { @@ -93,7 +109,12 @@ func Default() Config { func defaults() Config { return Config{ Server: ServerConfig{ - ListenAddr: defaultListenAddr, + ListenAddr: defaultListenAddr, + ReadHeaderTimeout: defaultReadHeaderTimeout, + ReadTimeout: defaultReadTimeout, + WriteTimeout: defaultWriteTimeout, + IdleTimeout: defaultIdleTimeout, + MaxHeaderBytes: defaultMaxHeaderBytes, }, Limits: LimitsConfig{ MaxUploadSize: defaultMaxUploadSize, @@ -122,6 +143,45 @@ func (c *Config) Validate() error { if strings.TrimSpace(c.Server.ListenAddr) == "" { return errors.New("server.listen_addr is required") } + readHeaderTimeout, err := time.ParseDuration(strings.TrimSpace(c.Server.ReadHeaderTimeout)) + if err != nil { + return fmt.Errorf("server.read_header_timeout invalid: %w", err) + } + if readHeaderTimeout <= 0 { + return errors.New("server.read_header_timeout must be > 0") + } + c.Server.ReadHeaderTimeoutDur = readHeaderTimeout + + readTimeout, err := time.ParseDuration(strings.TrimSpace(c.Server.ReadTimeout)) + if err != nil { + return fmt.Errorf("server.read_timeout invalid: %w", err) + } + if readTimeout <= 0 { + return errors.New("server.read_timeout must be > 0") + } + c.Server.ReadTimeoutDur = readTimeout + + writeTimeout, err := time.ParseDuration(strings.TrimSpace(c.Server.WriteTimeout)) + if err != nil { + return fmt.Errorf("server.write_timeout invalid: %w", err) + } + if writeTimeout <= 0 { + return errors.New("server.write_timeout must be > 0") + } + c.Server.WriteTimeoutDur = writeTimeout + + idleTimeout, err := time.ParseDuration(strings.TrimSpace(c.Server.IdleTimeout)) + if err != nil { + return fmt.Errorf("server.idle_timeout invalid: %w", err) + } + if idleTimeout <= 0 { + return errors.New("server.idle_timeout must be > 0") + } + c.Server.IdleTimeoutDur = idleTimeout + + if c.Server.MaxHeaderBytes <= 0 { + return errors.New("server.max_header_bytes must be > 0") + } sizeBytes, err := parseHumanSize(c.Limits.MaxUploadSize) if err != nil { @@ -168,6 +228,14 @@ func (c *Config) Validate() error { return fmt.Errorf("security.allowed_ips invalid: %w", err) } c.Security.AllowedPrefixes = prefixes + trustedProxyPrefixes, err := parseAllowedPrefixes(c.Security.TrustedProxyIPs) + if err != nil { + return fmt.Errorf("security.trusted_proxy_ips invalid: %w", err) + } + c.Security.TrustedProxyPrefixes = trustedProxyPrefixes + if c.Security.TrustProxyHeaders && len(c.Security.TrustedProxyPrefixes) == 0 { + return errors.New("security.trusted_proxy_ips is required when security.trust_proxy_headers is true") + } if c.Security.RateLimit.RequestsPerMinute <= 0 { return errors.New("security.rate_limit.requests_per_minute must be > 0") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0f6ff3e..1259b32 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -22,6 +22,21 @@ func TestValidateAppliesDefaultsAndParsedFields(t *testing.T) { if cfg.Limits.MaxUploadSizeBytes != 100*1024*1024 { t.Fatalf("MaxUploadSizeBytes = %d, want %d", cfg.Limits.MaxUploadSizeBytes, int64(100*1024*1024)) } + if cfg.Server.ReadHeaderTimeoutDur != 5*time.Second { + t.Fatalf("ReadHeaderTimeoutDur = %s, want 5s", cfg.Server.ReadHeaderTimeoutDur) + } + if cfg.Server.ReadTimeoutDur != 30*time.Second { + t.Fatalf("ReadTimeoutDur = %s, want 30s", cfg.Server.ReadTimeoutDur) + } + if cfg.Server.WriteTimeoutDur != 30*time.Second { + t.Fatalf("WriteTimeoutDur = %s, want 30s", cfg.Server.WriteTimeoutDur) + } + if cfg.Server.IdleTimeoutDur != 120*time.Second { + t.Fatalf("IdleTimeoutDur = %s, want 120s", cfg.Server.IdleTimeoutDur) + } + if cfg.Server.MaxHeaderBytes != 1<<20 { + t.Fatalf("MaxHeaderBytes = %d, want %d", cfg.Server.MaxHeaderBytes, 1<<20) + } if cfg.Limits.DefaultTTLDuration != time.Hour { t.Fatalf("DefaultTTLDuration = %s, want 1h", cfg.Limits.DefaultTTLDuration) } @@ -51,6 +66,11 @@ func TestLoadParsesConfiguredFields(t *testing.T) { content := ` server: listen_addr: "127.0.0.1:9090" + read_header_timeout: "4s" + read_timeout: "25s" + write_timeout: "35s" + idle_timeout: "90s" + max_header_bytes: 2097152 limits: max_upload_size: "2MiB" default_ttl: "2h" @@ -64,6 +84,8 @@ security: - "127.0.0.1" - "10.0.0.0/8" trust_proxy_headers: true + trusted_proxy_ips: + - "10.0.0.0/8" rate_limit: enabled: true requests_per_minute: 12 @@ -81,6 +103,21 @@ security: if cfg.Server.ListenAddr != "127.0.0.1:9090" { t.Fatalf("ListenAddr = %q, want %q", cfg.Server.ListenAddr, "127.0.0.1:9090") } + if cfg.Server.ReadHeaderTimeoutDur != 4*time.Second { + t.Fatalf("ReadHeaderTimeoutDur = %s, want 4s", cfg.Server.ReadHeaderTimeoutDur) + } + if cfg.Server.ReadTimeoutDur != 25*time.Second { + t.Fatalf("ReadTimeoutDur = %s, want 25s", cfg.Server.ReadTimeoutDur) + } + if cfg.Server.WriteTimeoutDur != 35*time.Second { + t.Fatalf("WriteTimeoutDur = %s, want 35s", cfg.Server.WriteTimeoutDur) + } + if cfg.Server.IdleTimeoutDur != 90*time.Second { + t.Fatalf("IdleTimeoutDur = %s, want 90s", cfg.Server.IdleTimeoutDur) + } + if cfg.Server.MaxHeaderBytes != 2*1024*1024 { + t.Fatalf("MaxHeaderBytes = %d, want %d", cfg.Server.MaxHeaderBytes, 2*1024*1024) + } if cfg.Limits.MaxUploadSizeBytes != 2*1024*1024 { t.Fatalf("MaxUploadSizeBytes = %d, want %d", cfg.Limits.MaxUploadSizeBytes, int64(2*1024*1024)) } @@ -105,6 +142,9 @@ security: if len(cfg.Security.AllowedPrefixes) != 2 { t.Fatalf("AllowedPrefixes len = %d, want 2", len(cfg.Security.AllowedPrefixes)) } + if len(cfg.Security.TrustedProxyPrefixes) != 1 { + t.Fatalf("TrustedProxyPrefixes len = %d, want 1", len(cfg.Security.TrustedProxyPrefixes)) + } } func TestValidateRejectsInvalidValues(t *testing.T) { @@ -129,6 +169,41 @@ func TestValidateRejectsInvalidValues(t *testing.T) { }, wantErr: "limits.max_upload_size", }, + { + name: "invalid read header timeout", + mutate: func(cfg *Config) { + cfg.Server.ReadHeaderTimeout = "bogus" + }, + wantErr: "server.read_header_timeout", + }, + { + name: "invalid read timeout", + mutate: func(cfg *Config) { + cfg.Server.ReadTimeout = "bogus" + }, + wantErr: "server.read_timeout", + }, + { + name: "invalid write timeout", + mutate: func(cfg *Config) { + cfg.Server.WriteTimeout = "bogus" + }, + wantErr: "server.write_timeout", + }, + { + name: "invalid idle timeout", + mutate: func(cfg *Config) { + cfg.Server.IdleTimeout = "bogus" + }, + wantErr: "server.idle_timeout", + }, + { + name: "invalid max header bytes", + mutate: func(cfg *Config) { + cfg.Server.MaxHeaderBytes = 0 + }, + wantErr: "server.max_header_bytes", + }, { name: "invalid ttl parse", mutate: func(cfg *Config) { @@ -164,6 +239,21 @@ func TestValidateRejectsInvalidValues(t *testing.T) { }, wantErr: "security.allowed_ips", }, + { + name: "invalid trusted proxy entry", + mutate: func(cfg *Config) { + cfg.Security.TrustedProxyIPs = []string{"not-an-ip"} + }, + wantErr: "security.trusted_proxy_ips", + }, + { + name: "trust proxy headers requires trusted proxies", + mutate: func(cfg *Config) { + cfg.Security.TrustProxyHeaders = true + cfg.Security.TrustedProxyIPs = nil + }, + wantErr: "security.trusted_proxy_ips is required", + }, { name: "invalid rate limit burst", mutate: func(cfg *Config) { diff --git a/internal/http/handlers.go b/internal/http/handlers.go index a6d1437..033674b 100644 --- a/internal/http/handlers.go +++ b/internal/http/handlers.go @@ -44,10 +44,10 @@ func (s *Server) Routes() http.Handler { writeHandler := http.HandlerFunc(s.createScratch) middlewares := make([]func(http.Handler) http.Handler, 0, 2) - middlewares = append(middlewares, AllowlistMiddleware(s.cfg.Security.AllowedPrefixes, s.cfg.Security.TrustProxyHeaders, s.logger)) + middlewares = append(middlewares, AllowlistMiddleware(s.cfg.Security.AllowedPrefixes, s.cfg.Security.TrustProxyHeaders, s.cfg.Security.TrustedProxyPrefixes, s.logger)) if s.cfg.Security.RateLimit.Enabled { limiter := NewRateLimiter(s.cfg.Security.RateLimit.RequestsPerMinute, s.cfg.Security.RateLimit.Burst) - middlewares = append(middlewares, RateLimitMiddleware(limiter, s.cfg.Security.TrustProxyHeaders)) + middlewares = append(middlewares, RateLimitMiddleware(limiter, s.cfg.Security.TrustProxyHeaders, s.cfg.Security.TrustedProxyPrefixes)) } mux.Handle("POST /api/scratch", Chain(writeHandler, middlewares...)) @@ -298,7 +298,7 @@ func (s *Server) isUploadAllowedForRequest(r *http.Request) bool { return true } - clientIP, ok := extractClientIP(r, s.cfg.Security.TrustProxyHeaders) + clientIP, ok := extractClientIP(r, s.cfg.Security.TrustProxyHeaders, s.cfg.Security.TrustedProxyPrefixes) if !ok { return false } diff --git a/internal/http/middleware.go b/internal/http/middleware.go index 98ead42..6fe6781 100644 --- a/internal/http/middleware.go +++ b/internal/http/middleware.go @@ -59,14 +59,14 @@ func (r *RateLimiter) Allow(ip string) bool { return v.limiter.Allow() } -func AllowlistMiddleware(allowed []netip.Prefix, trustProxyHeaders bool, logger *log.Logger) func(http.Handler) http.Handler { +func AllowlistMiddleware(allowed []netip.Prefix, trustProxyHeaders bool, trustedProxies []netip.Prefix, logger *log.Logger) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { if len(allowed) == 0 { return next } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - clientIP, ok := extractClientIP(r, trustProxyHeaders) + clientIP, ok := extractClientIP(r, trustProxyHeaders, trustedProxies) if !ok { http.Error(w, "unable to determine client ip", http.StatusForbidden) return @@ -85,10 +85,10 @@ func AllowlistMiddleware(allowed []netip.Prefix, trustProxyHeaders bool, logger } } -func RateLimitMiddleware(limiter *RateLimiter, trustProxyHeaders bool) func(http.Handler) http.Handler { +func RateLimitMiddleware(limiter *RateLimiter, trustProxyHeaders bool, trustedProxies []netip.Prefix) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - clientIP, ok := extractClientIP(r, trustProxyHeaders) + clientIP, ok := extractClientIP(r, trustProxyHeaders, trustedProxies) if !ok { http.Error(w, "unable to determine client ip", http.StatusTooManyRequests) return @@ -115,8 +115,13 @@ func Chain(handler http.Handler, middlewares ...func(http.Handler) http.Handler) return wrapped } -func extractClientIP(r *http.Request, trustProxyHeaders bool) (netip.Addr, bool) { - if trustProxyHeaders { +func extractClientIP(r *http.Request, trustProxyHeaders bool, trustedProxies []netip.Prefix) (netip.Addr, bool) { + remoteIP, ok := remoteIPFromRequest(r) + if !ok { + return netip.Addr{}, false + } + + if trustProxyHeaders && isTrustedProxy(remoteIP, trustedProxies) { if xff := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); xff != "" { parts := strings.Split(xff, ",") if len(parts) > 0 { @@ -131,7 +136,10 @@ func extractClientIP(r *http.Request, trustProxyHeaders bool) (netip.Addr, bool) } } } + return remoteIP, true +} +func remoteIPFromRequest(r *http.Request) (netip.Addr, bool) { host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr)) if err != nil { if ip, parseErr := netip.ParseAddr(strings.TrimSpace(r.RemoteAddr)); parseErr == nil { @@ -146,6 +154,15 @@ func extractClientIP(r *http.Request, trustProxyHeaders bool) (netip.Addr, bool) return ip, true } +func isTrustedProxy(remoteIP netip.Addr, trusted []netip.Prefix) bool { + for _, prefix := range trusted { + if prefix.Contains(remoteIP) { + return true + } + } + return false +} + func writeJSON(w http.ResponseWriter, status int, payload any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) diff --git a/internal/http/middleware_test.go b/internal/http/middleware_test.go index 2d0e969..8eb1490 100644 --- a/internal/http/middleware_test.go +++ b/internal/http/middleware_test.go @@ -17,7 +17,7 @@ func TestAllowlistMiddlewareDeniesAndAllowsByIP(t *testing.T) { logger := log.New(io.Discard, "", 0) allowed := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")} - mw := AllowlistMiddleware(allowed, false, logger) + mw := AllowlistMiddleware(allowed, false, nil, logger) next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) @@ -46,7 +46,8 @@ func TestAllowlistMiddlewareHonorsProxyHeaderWhenTrusted(t *testing.T) { logger := log.New(io.Discard, "", 0) allowed := []netip.Prefix{netip.MustParsePrefix("203.0.113.5/32")} - mw := AllowlistMiddleware(allowed, true, logger) + trustedProxies := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")} + mw := AllowlistMiddleware(allowed, true, trustedProxies, logger) handler := mw(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })) @@ -66,7 +67,7 @@ func TestRateLimitMiddlewareKeysByClientIP(t *testing.T) { t.Parallel() limiter := NewRateLimiter(60, 1) - handler := RateLimitMiddleware(limiter, false)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + handler := RateLimitMiddleware(limiter, false, nil)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })) @@ -98,7 +99,7 @@ func TestRateLimitMiddlewareKeysByClientIP(t *testing.T) { func TestAllowlistMiddlewareNoRestrictionsReturnsNext(t *testing.T) { t.Parallel() - handler := AllowlistMiddleware(nil, false, log.New(io.Discard, "", 0))(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + handler := AllowlistMiddleware(nil, false, nil, log.New(io.Discard, "", 0))(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusAccepted) })) req := httptest.NewRequest(http.MethodPost, "/api/scratch", nil) @@ -115,7 +116,7 @@ func TestAllowlistMiddlewareRejectsUnknownClientIP(t *testing.T) { logger := log.New(io.Discard, "", 0) allowed := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")} - handler := AllowlistMiddleware(allowed, false, logger)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + handler := AllowlistMiddleware(allowed, false, nil, logger)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })) req := httptest.NewRequest(http.MethodPost, "/api/scratch", nil) @@ -131,7 +132,7 @@ func TestRateLimitMiddlewareUnknownIP(t *testing.T) { t.Parallel() limiter := NewRateLimiter(60, 1) - handler := RateLimitMiddleware(limiter, false)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + handler := RateLimitMiddleware(limiter, false, nil)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })) req := httptest.NewRequest(http.MethodPost, "/api/scratch", nil) @@ -147,7 +148,7 @@ func TestRateLimitMiddleware429PayloadAndRetryHeader(t *testing.T) { t.Parallel() limiter := NewRateLimiter(1, 1) - handler := RateLimitMiddleware(limiter, false)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + handler := RateLimitMiddleware(limiter, false, nil)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })) req1 := httptest.NewRequest(http.MethodPost, "/api/scratch", nil) @@ -195,7 +196,7 @@ func TestExtractClientIPFallbacks(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/", nil) req.RemoteAddr = "203.0.113.9" - ip, ok := extractClientIP(req, false) + ip, ok := extractClientIP(req, false, nil) if !ok || ip.String() != "203.0.113.9" { t.Fatalf("extract direct ip = (%v,%v), want (203.0.113.9,true)", ip, ok) } @@ -204,14 +205,26 @@ func TestExtractClientIPFallbacks(t *testing.T) { req2.RemoteAddr = "192.0.2.50:8080" req2.Header.Set("X-Forwarded-For", "bad,198.51.100.1") req2.Header.Set("X-Real-IP", "198.51.100.5") - ip2, ok2 := extractClientIP(req2, true) + ip2, ok2 := extractClientIP(req2, true, []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")}) if !ok2 || ip2.String() != "198.51.100.5" { t.Fatalf("extract x-real-ip = (%v,%v), want (198.51.100.5,true)", ip2, ok2) } req3 := httptest.NewRequest(http.MethodGet, "/", nil) req3.RemoteAddr = "still-not-an-ip" - if _, ok3 := extractClientIP(req3, false); ok3 { + if _, ok3 := extractClientIP(req3, false, nil); ok3 { t.Fatalf("extractClientIP() ok = true, want false") } } + +func TestExtractClientIPIgnoresHeadersFromUntrustedProxy(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "203.0.113.10:8080" + req.Header.Set("X-Forwarded-For", "198.51.100.5") + ip, ok := extractClientIP(req, true, []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}) + if !ok || ip.String() != "203.0.113.10" { + t.Fatalf("extractClientIP() = (%v,%v), want (203.0.113.10,true)", ip, ok) + } +}