diff --git a/README.md b/README.md index 0c8c8c2..12bc14b 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ All configuration is YAML. - `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 and scratch-read routes (`POST /api/scratch`, `GET /api/scratch/{id}`, `GET /s/{id}`, `GET /api/raw/{id}`). - Default: `true` - `rate_limit.requests_per_minute`: steady refill rate per client IP. - Must be `> 0` diff --git a/internal/config/config.go b/internal/config/config.go index 2b43ddf..8d6242f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -73,13 +73,13 @@ 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 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."` + RateLimit RateLimitConfig `yaml:"rate_limit" comment:"Per-client token bucket on write and scratch read routes."` AllowedPrefixes []netip.Prefix `yaml:"-"` TrustedProxyPrefixes []netip.Prefix `yaml:"-"` } type RateLimitConfig struct { - Enabled bool `yaml:"enabled" comment:"Enable per-client write-route rate limiting."` + Enabled bool `yaml:"enabled" comment:"Enable per-client rate limiting for write and scratch read routes."` RequestsPerMinute int `yaml:"requests_per_minute" comment:"Steady refill rate per client IP."` Burst int `yaml:"burst" comment:"Maximum immediate burst capacity per client IP."` } diff --git a/internal/http/handlers.go b/internal/http/handlers.go index 033674b..2c679df 100644 --- a/internal/http/handlers.go +++ b/internal/http/handlers.go @@ -38,18 +38,21 @@ func (s *Server) Routes() http.Handler { mux.Handle("GET /{$}", http.HandlerFunc(s.serveSPA)) mux.Handle("GET /u", http.HandlerFunc(s.serveSPA)) mux.Handle("GET /api/config", http.HandlerFunc(s.getUIConfig)) - mux.Handle("GET /api/scratch/{id}", http.HandlerFunc(s.getScratch)) - mux.Handle("GET /s/{id}", http.HandlerFunc(s.scratchPage)) - mux.Handle("GET /api/raw/{id}", http.HandlerFunc(s.rawScratch)) 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.cfg.Security.TrustedProxyPrefixes, s.logger)) + readMiddlewares := make([]func(http.Handler) http.Handler, 0, 1) 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, s.cfg.Security.TrustedProxyPrefixes)) + writeLimiter := NewRateLimiter(s.cfg.Security.RateLimit.RequestsPerMinute, s.cfg.Security.RateLimit.Burst) + readLimiter := NewRateLimiter(s.cfg.Security.RateLimit.RequestsPerMinute, s.cfg.Security.RateLimit.Burst) + middlewares = append(middlewares, RateLimitMiddleware(writeLimiter, s.cfg.Security.TrustProxyHeaders, s.cfg.Security.TrustedProxyPrefixes)) + readMiddlewares = append(readMiddlewares, RateLimitMiddleware(readLimiter, s.cfg.Security.TrustProxyHeaders, s.cfg.Security.TrustedProxyPrefixes)) } mux.Handle("POST /api/scratch", Chain(writeHandler, middlewares...)) + mux.Handle("GET /api/scratch/{id}", Chain(http.HandlerFunc(s.getScratch), readMiddlewares...)) + mux.Handle("GET /s/{id}", Chain(http.HandlerFunc(s.scratchPage), readMiddlewares...)) + mux.Handle("GET /api/raw/{id}", Chain(http.HandlerFunc(s.rawScratch), readMiddlewares...)) mux.Handle("GET /assets/", http.FileServerFS(webassets.StaticFS())) mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(webassets.StaticFS()))) diff --git a/internal/http/handlers_integration_test.go b/internal/http/handlers_integration_test.go index 96e96c2..d0c985b 100644 --- a/internal/http/handlers_integration_test.go +++ b/internal/http/handlers_integration_test.go @@ -151,6 +151,50 @@ func TestUploadedScratchViewAndRawBeforeAndAfterExpiry(t *testing.T) { } } +func TestReadRoutesAreRateLimitedWhenEnabled(t *testing.T) { + t.Parallel() + + handler := newTestHandler(t, testServerOptions{ + maxUploadBytes: 1024, + defaultTTL: time.Hour, + rateLimitEnable: true, + }) + + createReq := httptest.NewRequest(http.MethodPost, "/api/scratch", bytes.NewBufferString("hello scratch")) + createReq.Header.Set("Content-Type", "text/plain; charset=utf-8") + createReq.RemoteAddr = "127.0.0.1:9111" + createRec := httptest.NewRecorder() + handler.ServeHTTP(createRec, createReq) + if createRec.Code != http.StatusCreated { + t.Fatalf("create status = %d, want %d", createRec.Code, http.StatusCreated) + } + + var payload map[string]any + if err := json.Unmarshal(createRec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode create payload error = %v", err) + } + id, _ := payload["id"].(string) + if id == "" { + t.Fatalf("create payload missing id") + } + + firstRawReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil) + firstRawReq.RemoteAddr = "127.0.0.1:9111" + firstRawRec := httptest.NewRecorder() + handler.ServeHTTP(firstRawRec, firstRawReq) + if firstRawRec.Code != http.StatusOK { + t.Fatalf("first raw status = %d, want %d", firstRawRec.Code, http.StatusOK) + } + + secondRawReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil) + secondRawReq.RemoteAddr = "127.0.0.1:9111" + secondRawRec := httptest.NewRecorder() + handler.ServeHTTP(secondRawRec, secondRawReq) + if secondRawRec.Code != http.StatusTooManyRequests { + t.Fatalf("second raw status = %d, want %d", secondRawRec.Code, http.StatusTooManyRequests) + } +} + func TestCreateScratchRejectsBodyOverLimit(t *testing.T) { t.Parallel()