fix(backend): rate-limit and cap report ingest, drop wildcard CORS (closes #20)
check / check (push) Successful in 58s
check / check (push) Successful in 58s
POST /api/v1/reports stays unauthenticated but is bounded. Each client address, as the trusted-proxy logic resolves it, may send REPORTS_PER_MINUTE reports a minute (default 60, all at once if it likes), using golang.org/x/time/rate; past that it gets 429 with Retry-After. Buckets that have refilled are dropped once a minute, so idle addresses do not pile up. reportbuf refuses a report that would take the report files past DATA_DIR_MAX_BYTES (default 1 GiB) with ErrFull, answered with 507; the count starts from the files already in DATA_DIR, and reports not yet written count at their uncompressed size. CORS adds nothing unless CORS_ALLOWED_ORIGINS lists origins. A limit that is not a positive number stops the server from starting. Model: opus-5-5
This commit is contained in:
@@ -7,11 +7,14 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/netwatch/internal/config"
|
||||
@@ -21,6 +24,7 @@ import (
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
"go.uber.org/fx"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
const corsMaxAgeSec = 300
|
||||
@@ -320,21 +324,98 @@ func (s *Middleware) Recoverer() func(http.Handler) http.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// CORS returns middleware that adds permissive CORS headers.
|
||||
func (s *Middleware) CORS() func(http.Handler) http.Handler {
|
||||
// CORS returns middleware that lets pages served from the given
|
||||
// origins call the API. With no origins it adds no CORS headers at
|
||||
// all, so only same-origin pages can use the API. That case must not
|
||||
// reach cors.Handler, which treats an empty origin list as "allow
|
||||
// every origin".
|
||||
func (s *Middleware) CORS(
|
||||
origins []string,
|
||||
) func(http.Handler) http.Handler {
|
||||
if len(origins) == 0 {
|
||||
return func(next http.Handler) http.Handler { return next }
|
||||
}
|
||||
|
||||
return cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{
|
||||
"GET", "POST", "PUT", "DELETE", "OPTIONS",
|
||||
},
|
||||
AllowedHeaders: []string{
|
||||
"Accept",
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"X-CSRF-Token",
|
||||
},
|
||||
ExposedHeaders: []string{"Link"},
|
||||
AllowedOrigins: origins,
|
||||
AllowedMethods: []string{http.MethodGet, http.MethodPost},
|
||||
AllowedHeaders: []string{"Content-Type"},
|
||||
AllowCredentials: false,
|
||||
MaxAge: corsMaxAgeSec,
|
||||
})
|
||||
}
|
||||
|
||||
// RateLimit returns middleware that allows each client address
|
||||
// perMinute requests a minute, all at once if it likes, and answers
|
||||
// the rest with 429 and a Retry-After header. The address is the one
|
||||
// clientIP resolves, so clients behind the reverse proxy are limited
|
||||
// one by one, not together as the proxy.
|
||||
func (s *Middleware) RateLimit(
|
||||
perMinute int,
|
||||
) func(http.Handler) http.Handler {
|
||||
// One request's allowance comes back every interval, so a
|
||||
// refused client can always retry after it.
|
||||
interval := time.Minute / time.Duration(perMinute)
|
||||
retryAfter := strconv.Itoa(int(math.Ceil(interval.Seconds())))
|
||||
|
||||
limiter := &addressLimiter{
|
||||
burst: perMinute,
|
||||
byAddr: make(map[string]*rate.Limiter),
|
||||
limit: rate.Every(interval),
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
addr := clientIP(r.RemoteAddr, r.Header, s.trustedProxies)
|
||||
|
||||
if !limiter.allow(addr, time.Now()) {
|
||||
w.Header().Set("Retry-After", retryAfter)
|
||||
writeJSONError(w, http.StatusTooManyRequests)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// addressLimiter holds a token bucket for each client address that
|
||||
// has made a request recently.
|
||||
type addressLimiter struct {
|
||||
burst int
|
||||
byAddr map[string]*rate.Limiter
|
||||
lastSweep time.Time
|
||||
limit rate.Limit
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// allow takes a token from addr's bucket, which starts full, and
|
||||
// reports whether there was one. At most once a minute it first drops
|
||||
// every bucket that has filled up again: a full bucket behaves exactly
|
||||
// like the new one that would replace it, so this changes no answer,
|
||||
// and the map holds only the addresses heard from recently.
|
||||
func (a *addressLimiter) allow(addr string, now time.Time) bool {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
if now.Sub(a.lastSweep) >= time.Minute {
|
||||
for key, bucket := range a.byAddr {
|
||||
if bucket.TokensAt(now) >= float64(a.burst) {
|
||||
delete(a.byAddr, key)
|
||||
}
|
||||
}
|
||||
|
||||
a.lastSweep = now
|
||||
}
|
||||
|
||||
bucket, ok := a.byAddr[addr]
|
||||
if !ok {
|
||||
bucket = rate.NewLimiter(a.limit, a.burst)
|
||||
a.byAddr[addr] = bucket
|
||||
}
|
||||
|
||||
return bucket.AllowN(now, 1)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user