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:
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user