Harden HTTP defaults and proxy header trust.

Add explicit server timeout/header limits, require trusted proxy CIDRs when honoring forwarded IP headers, and document single-instance storage expectations.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-31 21:09:00 -05:00
parent dbf112a5b7
commit dbb72da312
7 changed files with 246 additions and 27 deletions
+26
View File
@@ -28,6 +28,21 @@ All configuration is YAML.
- `listen_addr`: address for the HTTP server (required). - `listen_addr`: address for the HTTP server (required).
- Default: `:8080` - 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` ### `limits`
@@ -64,7 +79,11 @@ All configuration is YAML.
- Add trusted IPs/CIDRs for LAN/proxy clients (for example `192.168.1.0/24`) - Add trusted IPs/CIDRs for LAN/proxy clients (for example `192.168.1.0/24`)
- Empty list means no allowlist restriction - Empty list means no allowlist restriction
- `trust_proxy_headers`: if `true`, client IP extraction honors `X-Forwarded-For` then `X-Real-IP`. - `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. - 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. - `rate_limit.enabled`: enable per-IP token-bucket rate limiting on write route.
- Default: `true` - Default: `true`
- `rate_limit.requests_per_minute`: steady refill rate per client IP. - `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. - 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. 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.
+7 -2
View File
@@ -59,8 +59,13 @@ func run(configPath string) error {
go cleanupWorker.Start(ctx) go cleanupWorker.Start(ctx)
httpServer := &http.Server{ httpServer := &http.Server{
Addr: cfg.Server.ListenAddr, Addr: cfg.Server.ListenAddr,
Handler: server.Routes(), 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() { go func() {
+74 -6
View File
@@ -15,6 +15,11 @@ import (
const ( const (
defaultListenAddr = ":8080" defaultListenAddr = ":8080"
defaultReadHeaderTimeout = "5s"
defaultReadTimeout = "30s"
defaultWriteTimeout = "30s"
defaultIdleTimeout = "120s"
defaultMaxHeaderBytes = 1 << 20
defaultMaxUploadSize = "100MiB" defaultMaxUploadSize = "100MiB"
defaultTTL = "1h" defaultTTL = "1h"
defaultRawCacheMaxSize = "200MiB" defaultRawCacheMaxSize = "200MiB"
@@ -36,7 +41,16 @@ type Config struct {
} }
type ServerConfig 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 { type LimitsConfig struct {
@@ -56,10 +70,12 @@ type StorageConfig struct {
} }
type SecurityConfig 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."` 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)."` TrustProxyHeaders bool `yaml:"trust_proxy_headers" comment:"Honor X-Forwarded-For / X-Real-IP only when request source IP matches security.trusted_proxy_ips."`
RateLimit RateLimitConfig `yaml:"rate_limit" comment:"Per-client token bucket on POST /api/scratch."` 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."`
AllowedPrefixes []netip.Prefix `yaml:"-"` 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 { type RateLimitConfig struct {
@@ -93,7 +109,12 @@ func Default() Config {
func defaults() Config { func defaults() Config {
return Config{ return Config{
Server: ServerConfig{ Server: ServerConfig{
ListenAddr: defaultListenAddr, ListenAddr: defaultListenAddr,
ReadHeaderTimeout: defaultReadHeaderTimeout,
ReadTimeout: defaultReadTimeout,
WriteTimeout: defaultWriteTimeout,
IdleTimeout: defaultIdleTimeout,
MaxHeaderBytes: defaultMaxHeaderBytes,
}, },
Limits: LimitsConfig{ Limits: LimitsConfig{
MaxUploadSize: defaultMaxUploadSize, MaxUploadSize: defaultMaxUploadSize,
@@ -122,6 +143,45 @@ func (c *Config) Validate() error {
if strings.TrimSpace(c.Server.ListenAddr) == "" { if strings.TrimSpace(c.Server.ListenAddr) == "" {
return errors.New("server.listen_addr is required") 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) sizeBytes, err := parseHumanSize(c.Limits.MaxUploadSize)
if err != nil { if err != nil {
@@ -168,6 +228,14 @@ func (c *Config) Validate() error {
return fmt.Errorf("security.allowed_ips invalid: %w", err) return fmt.Errorf("security.allowed_ips invalid: %w", err)
} }
c.Security.AllowedPrefixes = prefixes 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 { if c.Security.RateLimit.RequestsPerMinute <= 0 {
return errors.New("security.rate_limit.requests_per_minute must be > 0") return errors.New("security.rate_limit.requests_per_minute must be > 0")
+90
View File
@@ -22,6 +22,21 @@ func TestValidateAppliesDefaultsAndParsedFields(t *testing.T) {
if cfg.Limits.MaxUploadSizeBytes != 100*1024*1024 { if cfg.Limits.MaxUploadSizeBytes != 100*1024*1024 {
t.Fatalf("MaxUploadSizeBytes = %d, want %d", cfg.Limits.MaxUploadSizeBytes, int64(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 { if cfg.Limits.DefaultTTLDuration != time.Hour {
t.Fatalf("DefaultTTLDuration = %s, want 1h", cfg.Limits.DefaultTTLDuration) t.Fatalf("DefaultTTLDuration = %s, want 1h", cfg.Limits.DefaultTTLDuration)
} }
@@ -51,6 +66,11 @@ func TestLoadParsesConfiguredFields(t *testing.T) {
content := ` content := `
server: server:
listen_addr: "127.0.0.1:9090" 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: limits:
max_upload_size: "2MiB" max_upload_size: "2MiB"
default_ttl: "2h" default_ttl: "2h"
@@ -64,6 +84,8 @@ security:
- "127.0.0.1" - "127.0.0.1"
- "10.0.0.0/8" - "10.0.0.0/8"
trust_proxy_headers: true trust_proxy_headers: true
trusted_proxy_ips:
- "10.0.0.0/8"
rate_limit: rate_limit:
enabled: true enabled: true
requests_per_minute: 12 requests_per_minute: 12
@@ -81,6 +103,21 @@ security:
if cfg.Server.ListenAddr != "127.0.0.1:9090" { if cfg.Server.ListenAddr != "127.0.0.1:9090" {
t.Fatalf("ListenAddr = %q, want %q", 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 { if cfg.Limits.MaxUploadSizeBytes != 2*1024*1024 {
t.Fatalf("MaxUploadSizeBytes = %d, want %d", cfg.Limits.MaxUploadSizeBytes, int64(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 { if len(cfg.Security.AllowedPrefixes) != 2 {
t.Fatalf("AllowedPrefixes len = %d, want 2", len(cfg.Security.AllowedPrefixes)) 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) { func TestValidateRejectsInvalidValues(t *testing.T) {
@@ -129,6 +169,41 @@ func TestValidateRejectsInvalidValues(t *testing.T) {
}, },
wantErr: "limits.max_upload_size", 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", name: "invalid ttl parse",
mutate: func(cfg *Config) { mutate: func(cfg *Config) {
@@ -164,6 +239,21 @@ func TestValidateRejectsInvalidValues(t *testing.T) {
}, },
wantErr: "security.allowed_ips", 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", name: "invalid rate limit burst",
mutate: func(cfg *Config) { mutate: func(cfg *Config) {
+3 -3
View File
@@ -44,10 +44,10 @@ func (s *Server) Routes() http.Handler {
writeHandler := http.HandlerFunc(s.createScratch) writeHandler := http.HandlerFunc(s.createScratch)
middlewares := make([]func(http.Handler) http.Handler, 0, 2) 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 { if s.cfg.Security.RateLimit.Enabled {
limiter := NewRateLimiter(s.cfg.Security.RateLimit.RequestsPerMinute, s.cfg.Security.RateLimit.Burst) 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...)) mux.Handle("POST /api/scratch", Chain(writeHandler, middlewares...))
@@ -298,7 +298,7 @@ func (s *Server) isUploadAllowedForRequest(r *http.Request) bool {
return true return true
} }
clientIP, ok := extractClientIP(r, s.cfg.Security.TrustProxyHeaders) clientIP, ok := extractClientIP(r, s.cfg.Security.TrustProxyHeaders, s.cfg.Security.TrustedProxyPrefixes)
if !ok { if !ok {
return false return false
} }
+23 -6
View File
@@ -59,14 +59,14 @@ func (r *RateLimiter) Allow(ip string) bool {
return v.limiter.Allow() 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 { return func(next http.Handler) http.Handler {
if len(allowed) == 0 { if len(allowed) == 0 {
return next return next
} }
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
clientIP, ok := extractClientIP(r, trustProxyHeaders) clientIP, ok := extractClientIP(r, trustProxyHeaders, trustedProxies)
if !ok { if !ok {
http.Error(w, "unable to determine client ip", http.StatusForbidden) http.Error(w, "unable to determine client ip", http.StatusForbidden)
return 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 func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
clientIP, ok := extractClientIP(r, trustProxyHeaders) clientIP, ok := extractClientIP(r, trustProxyHeaders, trustedProxies)
if !ok { if !ok {
http.Error(w, "unable to determine client ip", http.StatusTooManyRequests) http.Error(w, "unable to determine client ip", http.StatusTooManyRequests)
return return
@@ -115,8 +115,13 @@ func Chain(handler http.Handler, middlewares ...func(http.Handler) http.Handler)
return wrapped return wrapped
} }
func extractClientIP(r *http.Request, trustProxyHeaders bool) (netip.Addr, bool) { func extractClientIP(r *http.Request, trustProxyHeaders bool, trustedProxies []netip.Prefix) (netip.Addr, bool) {
if trustProxyHeaders { 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 != "" { if xff := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); xff != "" {
parts := strings.Split(xff, ",") parts := strings.Split(xff, ",")
if len(parts) > 0 { 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)) host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
if err != nil { if err != nil {
if ip, parseErr := netip.ParseAddr(strings.TrimSpace(r.RemoteAddr)); parseErr == 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 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) { func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status) w.WriteHeader(status)
+23 -10
View File
@@ -17,7 +17,7 @@ func TestAllowlistMiddlewareDeniesAndAllowsByIP(t *testing.T) {
logger := log.New(io.Discard, "", 0) logger := log.New(io.Discard, "", 0)
allowed := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")} 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) { next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
@@ -46,7 +46,8 @@ func TestAllowlistMiddlewareHonorsProxyHeaderWhenTrusted(t *testing.T) {
logger := log.New(io.Discard, "", 0) logger := log.New(io.Discard, "", 0)
allowed := []netip.Prefix{netip.MustParsePrefix("203.0.113.5/32")} 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) { handler := mw(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
})) }))
@@ -66,7 +67,7 @@ func TestRateLimitMiddlewareKeysByClientIP(t *testing.T) {
t.Parallel() t.Parallel()
limiter := NewRateLimiter(60, 1) 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) w.WriteHeader(http.StatusNoContent)
})) }))
@@ -98,7 +99,7 @@ func TestRateLimitMiddlewareKeysByClientIP(t *testing.T) {
func TestAllowlistMiddlewareNoRestrictionsReturnsNext(t *testing.T) { func TestAllowlistMiddlewareNoRestrictionsReturnsNext(t *testing.T) {
t.Parallel() 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) w.WriteHeader(http.StatusAccepted)
})) }))
req := httptest.NewRequest(http.MethodPost, "/api/scratch", nil) req := httptest.NewRequest(http.MethodPost, "/api/scratch", nil)
@@ -115,7 +116,7 @@ func TestAllowlistMiddlewareRejectsUnknownClientIP(t *testing.T) {
logger := log.New(io.Discard, "", 0) logger := log.New(io.Discard, "", 0)
allowed := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")} 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) w.WriteHeader(http.StatusNoContent)
})) }))
req := httptest.NewRequest(http.MethodPost, "/api/scratch", nil) req := httptest.NewRequest(http.MethodPost, "/api/scratch", nil)
@@ -131,7 +132,7 @@ func TestRateLimitMiddlewareUnknownIP(t *testing.T) {
t.Parallel() t.Parallel()
limiter := NewRateLimiter(60, 1) 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) w.WriteHeader(http.StatusNoContent)
})) }))
req := httptest.NewRequest(http.MethodPost, "/api/scratch", nil) req := httptest.NewRequest(http.MethodPost, "/api/scratch", nil)
@@ -147,7 +148,7 @@ func TestRateLimitMiddleware429PayloadAndRetryHeader(t *testing.T) {
t.Parallel() t.Parallel()
limiter := NewRateLimiter(1, 1) 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) w.WriteHeader(http.StatusNoContent)
})) }))
req1 := httptest.NewRequest(http.MethodPost, "/api/scratch", nil) req1 := httptest.NewRequest(http.MethodPost, "/api/scratch", nil)
@@ -195,7 +196,7 @@ func TestExtractClientIPFallbacks(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil) req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "203.0.113.9" 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" { if !ok || ip.String() != "203.0.113.9" {
t.Fatalf("extract direct ip = (%v,%v), want (203.0.113.9,true)", ip, ok) 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.RemoteAddr = "192.0.2.50:8080"
req2.Header.Set("X-Forwarded-For", "bad,198.51.100.1") req2.Header.Set("X-Forwarded-For", "bad,198.51.100.1")
req2.Header.Set("X-Real-IP", "198.51.100.5") 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" { if !ok2 || ip2.String() != "198.51.100.5" {
t.Fatalf("extract x-real-ip = (%v,%v), want (198.51.100.5,true)", ip2, ok2) t.Fatalf("extract x-real-ip = (%v,%v), want (198.51.100.5,true)", ip2, ok2)
} }
req3 := httptest.NewRequest(http.MethodGet, "/", nil) req3 := httptest.NewRequest(http.MethodGet, "/", nil)
req3.RemoteAddr = "still-not-an-ip" 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") 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)
}
}