feat: Rate limit API token requests

600 per minute per token, in the Redis that sessions already require.
Cookie sessions are untouched. A Redis failure falls through rather than
refusing traffic — it is already a larger problem and should not become a
second outage.
This commit is contained in:
2026-08-12 14:51:22 +00:00
parent 2685e9ad06
commit 3b4c87a292
3 changed files with 63 additions and 0 deletions
+1
View File
@@ -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.
+57
View File
@@ -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()
}
}
+5
View File
@@ -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 {