shomei-server-0.3.0.0: src/Shomei/Server/Middleware/RateLimit.hs
-- | A per-IP request-rate limiter as a WAI 'Middleware' (EP-2, M4).
--
-- This is an in-process token bucket (an STM @TVar (HashMap ByteString Bucket)@ keyed by client
-- IP), deliberately NOT a distributed/Redis store: this plan targets a single-instance
-- deployment (see the MasterPlan Decision Log). Each bucket holds up to @capacity@ tokens and
-- refills at @refillPerSec@; a request with no token left is rejected with HTTP 429 BEFORE it
-- reaches the Servant application or the database. The limiter is scoped to the unauthenticated
-- POST endpoints (login/signup/refresh and EP-1's verify-email/password-reset request routes) so
-- authenticated traffic bearing a valid token is never throttled, and it is a no-op when
-- @rateLimitEnabled@ is False.
--
-- Distinct from the per-account brute-force lockout (which is PostgreSQL-backed and survives
-- restarts): this request-rate state is in-memory and resets on restart, which is acceptable
-- because it bounds raw request volume, not authentication failures.
--
-- == Bucket eviction (EP-4)
--
-- A bucket per client IP that is never removed is an unbounded-memory denial-of-service
-- primitive: a slow scan of the address space grows the map forever. Every @sweepEvery@
-- throttled requests, 'takeToken' therefore prunes — inside the same STM transaction it was
-- already running — every bucket that has refilled to capacity.
--
-- That policy is /semantically lossless/, which is why it needs no tuning knob: 'takeToken'
-- treats an absent key as a fresh full bucket, so a full bucket and a missing bucket are
-- indistinguishable to every subsequent request. No request that would have been throttled is
-- admitted, and none that would have been admitted is throttled. Cost is @O(size)@ once per
-- @sweepEvery@ calls, i.e. amortized @O(1)@. An idle server never prunes, which is harmless:
-- no requests means no new buckets either, so the map cannot grow while unpruned.
--
-- == Contention
--
-- All buckets live in one @TVar (HashMap ByteString Bucket)@ whose root pointer every
-- throttled request rewrites, so concurrent 'takeToken' transactions conflict and retry: the
-- limiter is a serialization point under very high concurrency. This is accepted for the
-- documented single-instance posture (throttled routes are the unauthenticated auth endpoints,
-- which are low-volume by construction, and each is about to spend ~100 ms in Argon2 anyway).
-- If it ever profiles hot, the escape hatch is to shard the map across N @TVar@s keyed by a
-- hash of the client IP; nothing outside this module observes the single-@TVar@ representation.
module Shomei.Server.Middleware.RateLimit
( RateLimiter,
newRateLimiter,
newRateLimiterFor,
newRateLimiterWith,
rateLimitMiddleware,
throttledPath,
takeToken,
bucketCount,
)
where
import Control.Concurrent.STM (TVar, atomically, newTVarIO, readTVar, readTVarIO, writeTVar)
import Data.ByteString (ByteString)
import Data.HashMap.Strict (HashMap)
import Data.HashMap.Strict qualified as HM
import Data.Text.Encoding (encodeUtf8)
import GHC.Clock (getMonotonicTimeNSec)
import Network.Wai (Middleware, Request, pathInfo, remoteHost, requestMethod)
import Shomei.Config (RateLimitConfig (..))
import Shomei.Servant.Api (shomeiThrottledRoutes)
import Shomei.Servant.ClientIp (clientIpText)
import Shomei.Servant.Error (pcTooManyRequests, retryAfterOccurrence)
import Shomei.Servant.Middleware (problemResponse)
import Shomei.Servant.Throttle (ThrottledRoute, matchesThrottledRoute)
-- | One bucket per client IP: current token level + last-refill time (monotonic seconds).
data Bucket = Bucket !Double !Double
data RateLimiter = RateLimiter
{ buckets :: !(TVar (HashMap ByteString Bucket)),
-- | Counts 'takeToken' calls since the last prune; see 'sweepEvery'.
sweepCounter :: !(TVar Int),
-- | Prune fully-refilled buckets every this many 'takeToken' calls (always >= 1).
sweepEvery :: !Int,
-- | perIpBurst
capacity :: !Double,
-- | perIpRequestsPerMinute / 60
refillPerSec :: !Double,
enabled :: !Bool,
-- | Derived once from the API's 'Shomei.Servant.PreHandler.RateLimited' markers.
throttled :: ![ThrottledRoute]
}
-- | How many 'takeToken' calls pass between prunes. Large enough that the @O(size)@ prune is
-- lost in the noise of the Argon2 hash the throttled request is about to perform, small enough
-- that a scanning attacker cannot accumulate many buckets between sweeps.
defaultSweepEvery :: Int
defaultSweepEvery = 4096
-- | Build a limiter from the configured policy. Buckets are created lazily per client IP and
-- pruned once they refill (see the module haddock).
newRateLimiter :: RateLimitConfig -> IO RateLimiter
newRateLimiter = newRateLimiterFor shomeiThrottledRoutes
-- | Build a limiter over an explicitly derived route set. Embedding hosts can derive this from
-- their own Servant API; the standalone server passes 'shomeiThrottledRoutes'.
newRateLimiterFor :: [ThrottledRoute] -> RateLimitConfig -> IO RateLimiter
newRateLimiterFor = newRateLimiterWithFor defaultSweepEvery
-- | 'newRateLimiter' with the prune interval given explicitly. A test seam: production wants
-- 'newRateLimiter', but a test that had to issue 4096 requests to observe one prune would be
-- measuring the wrong thing. Values below 1 are clamped to 1.
newRateLimiterWith :: Int -> RateLimitConfig -> IO RateLimiter
newRateLimiterWith every = newRateLimiterWithFor every shomeiThrottledRoutes
newRateLimiterWithFor :: Int -> [ThrottledRoute] -> RateLimitConfig -> IO RateLimiter
newRateLimiterWithFor every routes cfg = do
tv <- newTVarIO HM.empty
counter <- newTVarIO 0
pure
RateLimiter
{ buckets = tv,
sweepCounter = counter,
sweepEvery = max 1 every,
capacity = fromIntegral cfg.perIpBurst,
refillPerSec = fromIntegral cfg.perIpRequestsPerMinute / 60,
enabled = cfg.rateLimitEnabled,
throttled = routes
}
-- | How many per-IP buckets the limiter is currently holding. Exposed so a test can assert the
-- map stays bounded; nothing in the server reads it.
bucketCount :: RateLimiter -> IO Int
bucketCount rl = HM.size <$> readTVarIO rl.buckets
-- | Try to take one token for @key@ at time @nowSecs@; 'True' = allowed, 'False' = rejected.
-- A new key starts with a full bucket.
--
-- Every @sweepEvery@ calls this also prunes fully-refilled buckets, in the same transaction.
-- The just-touched key survives its own sweep whenever it is not itself full — and when it is
-- full, dropping it changes nothing (see the module haddock's losslessness argument).
takeToken :: RateLimiter -> ByteString -> Double -> IO Bool
takeToken rl key nowSecs = atomically do
m <- readTVar rl.buckets
let Bucket lvl t0 = HM.lookupDefault (Bucket rl.capacity nowSecs) key m
refilled = min rl.capacity (lvl + (nowSecs - t0) * rl.refillPerSec)
allowed = refilled >= 1
taken = if allowed then refilled - 1 else refilled
updated = HM.insert key (Bucket taken nowSecs) m
sinceSweep <- readTVar rl.sweepCounter
if sinceSweep + 1 >= rl.sweepEvery
then do
writeTVar rl.sweepCounter 0
writeTVar rl.buckets (HM.filter (not . fullyRefilled) updated)
else do
writeTVar rl.sweepCounter (sinceSweep + 1)
writeTVar rl.buckets updated
pure allowed
where
-- Would this bucket have refilled to capacity by now? Then it is indistinguishable from an
-- absent key, and dropping it is observationally a no-op.
fullyRefilled (Bucket lvl t0) = lvl + (nowSecs - t0) * rl.refillPerSec >= rl.capacity
-- | The WAI middleware. Passes non-throttled paths straight through.
rateLimitMiddleware :: RateLimiter -> Middleware
rateLimitMiddleware rl app req respond
| not rl.enabled || not (throttledPath rl req) = app req respond
| otherwise = do
nowSecs <- monotonicSeconds
allowed <- takeToken rl (clientKey req) nowSecs
if allowed then app req respond else respond tooMany
where
-- The same RFC 9457 document every other application error path returns, with
-- @Retry-After@. This
-- answers before Servant ever routes the request, so it cannot go through the handler
-- error mapping — it shares the catalog constant instead.
tooMany = problemResponse pcTooManyRequests (retryAfterOccurrence 60)
-- | Match the request against the routes derived from the API type. Method, path length, and
-- every literal segment must agree; captures match exactly one segment.
throttledPath :: RateLimiter -> Request -> Bool
throttledPath rl req = matchesThrottledRoute rl.throttled (requestMethod req) (pathInfo req)
-- | The per-IP key: the client's host address WITHOUT the ephemeral source port (otherwise
-- each connection would get its own bucket). Behind a reverse proxy this is the proxy address; a
-- trusted @X-Forwarded-For@ policy is out of scope for this single-instance plan.
clientKey :: Request -> ByteString
clientKey = encodeUtf8 . clientIpText . remoteHost
monotonicSeconds :: IO Double
monotonicSeconds = (\ns -> fromIntegral ns / 1e9) <$> getMonotonicTimeNSec