Apply existing rate limiting to scratch read routes.
Reuse the current IP-based limiter for read endpoints so public reads remain open but throttled, and add integration coverage plus docs updates for the expanded scope. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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."`
|
||||
}
|
||||
|
||||
@@ -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())))
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user