diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index 13f232d..ecb5f06 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -53,6 +53,7 @@ func RegisterRoutes(r *gin.Engine) { // no-ops for cookie sessions. It is mounted here rather than per route so // a route added later is covered by where it lives, not by memory. apiGroup.Use(RequireScopes()) + apiGroup.Use(RateLimitTokens()) // Deny by default: every non-GET route under /api is gated unless it is on // the exemption list in licence.go. A route added later is covered because // of where it is mounted, not because someone remembered. diff --git a/server/internal/api/ratelimit.go b/server/internal/api/ratelimit.go new file mode 100644 index 0000000..ba6f66d --- /dev/null +++ b/server/internal/api/ratelimit.go @@ -0,0 +1,57 @@ +package api + +import ( + "net/http" + "strconv" + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth" + "github.com/gin-gonic/gin" +) + +// tokenRateLimit is per token per minute. It is not the general API +// rate-limiting project: it is only enough that a runaway script cannot take an +// instance down, and cookie sessions are deliberately untouched. +const tokenRateLimit = 600 + +// RateLimitTokens counts requests per token in a one-minute fixed window. +// +// A fixed window rather than a sliding one because the cost of a burst at a +// boundary is a script running twice as fast for one second, and a sliding +// window is a sorted set per token for that. +func RateLimitTokens() gin.HandlerFunc { + return func(c *gin.Context) { + if !auth.IsToken(c) { + c.Next() + return + } + rdb := auth.Redis() + if rdb == nil { + c.Next() + return + } + + window := time.Now().UTC().Unix() / 60 + key := "vantage:tokenrate:" + auth.TokenID(c) + ":" + strconv.FormatInt(window, 10) + + count, err := rdb.Incr(c.Request.Context(), key).Result() + if err != nil { + // Redis is already required for sessions, so it being down is a + // larger problem than this. Do not turn it into a second outage. + c.Next() + return + } + if count == 1 { + rdb.Expire(c.Request.Context(), key, 2*time.Minute) + } + if count > tokenRateLimit { + c.Header("Retry-After", "60") + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{ + "error": "rate limit exceeded for this API token", + "code": "rate_limited", + }) + return + } + c.Next() + } +} diff --git a/server/internal/auth/session.go b/server/internal/auth/session.go index 76e072e..888f74d 100644 --- a/server/internal/auth/session.go +++ b/server/internal/auth/session.go @@ -59,6 +59,11 @@ func PingRedis(ctx context.Context) error { return rdb.Ping(ctx).Err() } +// Redis exposes the session client for callers that need a counter rather than +// a session. There is one Redis in this deployment and adding a second client +// would double the connection pool for no reason. +func Redis() *redis.Client { return rdb } + func randomHex(n int) (string, error) { b := make([]byte, n) if _, err := rand.Read(b); err != nil {