npm.io
0.2.0 • Published 19h ago

airlock-ratelimit

Licence
MIT
Version
0.2.0
Deps
0
Size
59 kB
Vulns
0
Weekly
0

Rate-Limit / Abuse Guard

Drop-in rate limiting for Next.js + Supabase that doesn't punish families. Most rate limiters key by IP — which throttles everyone behind one NAT together, so a household or office gets locked out as a group. This one keys an authenticated request by its userId (each person gets their own bucket) and only falls back to the IP for truly anonymous traffic. Token bucket, zero dependencies, shipped .d.ts types, memory bounded by an LRU key cap.

npm i airlock-ratelimit

Route handler (App Router)

import { withRateLimit } from 'airlock-ratelimit/next'

export const POST = withRateLimit(
  async (req) => {
    // ... your handler
    return Response.json({ ok: true })
  },
  {
    limit: 30,        // per identified user, per window
    window: 10_000,   // ms
    ipLimit: 100,     // higher ceiling for anonymous traffic per IP
    trustProxy: true, // REQUIRED: your proxy appends x-forwarded-for (Vercel & most
                      // ingresses). Behind nginx use { ipHeader: 'x-real-ip' }; behind
                      // Cloudflare { ipHeader: 'cf-connecting-ip' }; with no proxy at
                      // all { trustProxy: false }. See "declare your proxy topology".
    identify: (req) => getVerifiedUserId(req), // server-verified id, or null if anon
  }
)

Over the limit → a 429 with Retry-After and rate-limit headers (both the de-facto X-RateLimit-* and the IETF-draft RateLimit-*). Under it → your handler runs, with the same headers attached.

Custom 429 (redirect, your own JSON, extra headers)

Pass onLimit to return your own response when the limit is hit — the rate-limit headers are still added where you leave them unset:

export const POST = withRateLimit(handler, {
  limit: 30,
  trustProxy: true,
  onLimit: (r, req) => Response.json(
    { message: `Devagar! Tente de novo em ${r.retryAfter}s.` },
    { status: 429 },
  ),
})

Just the limiter

import { rateLimit } from 'airlock-ratelimit'
import { getClientIp } from 'airlock-ratelimit/next'

const rl = rateLimit({ limit: 30, window: 10_000 })

// Derive the IP through getClientIp with your topology declared — do NOT pass a
// raw header. `req.headers.get('x-forwarded-for')` is a comma-separated list
// whose leftmost hops the caller chose; the limiter refuses to key on a value
// that is not an IP, so passing one lands every request on the unkeyable path.
const ip = getClientIp(req, { trustProxy: true })
const r = rl.check({ ip, userId })   // { ok, scope, limit, remaining, retryAfter, resetMs }
if (!r.ok) return tooMany(r)

Why the family thing matters

rateLimit({ limit: 2 }), one IP, two people:

rl.check({ ip, userId: 'mom' }) // ok
rl.check({ ip, userId: 'mom' }) // ok
rl.check({ ip, userId: 'mom' }) // 429 — mom hit HER limit
rl.check({ ip, userId: 'dad' }) // ok — dad is untouched

Key by user and a chatty app stops locking out whole households. (We learned this the hard way — 30 requests / 10s per IP was blocking families on one connection.)

Where this does NOT help: anonymous traffic. A request with no userId has nothing to key on but the IP, so the household shares one bucket again — and that includes login, signup and password reset, which is exactly where you most want a limit. ipLimit defaults to limit for anonymous traffic (it only defaults to limit × 10 when alsoLimitIp is on), so set ipLimit explicitly on those routes: roughly limit × (people/devices per IP). The headline is about authenticated traffic; be deliberate about the rest.

API

  • rateLimit({ limit?, window?, ipLimit?, alsoLimitIp?, maxKeys?, onUnkeyable?, now? }){ check, reset, sweep }
  • check({ ip, userId }){ ok, scope: 'user'|'ip'|'none', limit, remaining, retryAfter, resetMs }
  • reset({ ip, userId }) — clear a bucket. Ids and IPs are canonicalized exactly as check canonicalizes them, so reset({ userId: 0 }) and a padded id both hit the right bucket.
  • withRateLimit(handler, opts) — wrap a Next.js route handler. Takes everything rateLimit takes, plus identify, onLimit, ipHeader, trustProxy. Passing a pre-built limiter and a construction option throws, because the limiter already has its own.
  • getClientIp(req, opts?) — trusted client IP, canonicalized (IPv6 spellings like 2001:db8::1 / [2001:DB8::1] fold to one key, so they can't be rotated to dodge the limit). You must say which header to trust: opts.trustProxy: true uses the last (proxy-appended) x-forwarded-for hop, opts.ipHeader pins a single overwritten header (e.g. cf-connecting-ip), opts.trustProxy: false derives none. With neither, it returns null — see "you must declare your proxy topology" below for the two measured bypasses that is there to prevent.

Security & limits — read before shipping

  • No key at all → the request is DENIED (429) by default. When there is no userId and no usable IP, the limiter cannot tell callers apart, so it fails closed. This state is reachable by accident, not just in theory: trustProxy: false on anonymous traffic, an identify that returns null or throws, or a typo in the ipHeader you pinned. Previously this returned ok: true forever — a silent no-op where the route looked protected and wasn't. Set onUnkeyable: 'allow' to opt back into open behavior; it must be your declared choice, never a default you didn't know about.

  • identify must return a server-verified id (from a validated session or JWT). If you return a value read straight off a client header/param, an attacker forges an endless stream of ids and slips the limit. When you can't fully trust the id, set alsoLimitIp: true so the per-IP ceiling still applies to authenticated requests (defense in depth).

  • With alsoLimitIp, size ipLimit generously. Every authenticated request then also spends an IP token, so a too-low ipLimit lets one heavy user drain the shared ceiling and lock out the household — the very thing this limiter avoids. It defaults to limit × 10 when alsoLimitIp is on; set it to about limit × (people/devices per IP) for your app. (A request already denied by the user's own limit does not spend an IP token.)

  • You must declare your proxy topology — there is no safe default. Every client-IP header is attacker-supplied unless a proxy you control writes it, and which header that is depends on your deployment, not on the HTTP spec. Both possible defaults were tried here and both shipped a measured bypass: preferring x-real-ip let an attacker rotate it behind an XFF-appending proxy (300/300 through a limit: 5); preferring the last x-forwarded-for hop let an attacker send their own XFF behind an nginx that only overwrites x-real-ip, where it becomes the last hop (300/300). Same bypass, victim swapped. So getClientIp guesses nothing: with no topology declared it derives no IP, anonymous traffic is unkeyable, and the fail-closed default denies it. A one-time warning names your options. Declare one:

    { trustProxy: true }              // your proxy APPENDS x-forwarded-for (Vercel, most ingresses)
    { ipHeader: 'x-real-ip' }         // your proxy OVERWRITES one header (nginx proxy_set_header)
    { ipHeader: 'cf-connecting-ip' }  // Cloudflare
    { trustProxy: false }             // no proxy in front; rely on a server-verified identify()

    Within trustProxy: true, the hop that matters is the last one: a proxy appends the address it received from, so the leftmost hops are whatever the client sent. Keying on a leftmost hop lets an attacker rotate a forged entry and mint a bucket per request.

  • Count your trusted proxies. trustProxy: true reads the last XFF hop, which assumes exactly one trusted proxy sits in front of your app (the common Vercel / single-load-balancer case). If you run N proxies in a chain, the real client is the Nth-from-last hop — the last hop is then your inner proxy, not the client, so keying on it lumps every client behind that proxy into one bucket. With more than one proxy, pin the single header your edge sets with ipHeader (e.g. cf-connecting-ip behind Cloudflare, true-client-ip, or your load balancer's own header) instead of using last-hop.

  • The IP is canonicalized before it becomes a key. Equivalent spellings of one address — IPv6 case/::-expansion/brackets/zone-id, an IPv4-mapped ::ffff: 1.2.3.4 folded to its plain 1.2.3.4, IPv4 with surrounding whitespace or a :port — all fold to a single bucket key, so a client can't rotate representations to mint fresh buckets and multiply its budget. A value that is not an IP at all — raw header text, unknown, a hostname — is rejected rather than keyed, and falls to the unkeyable path. Both happen inside the limiter itself, so they hold whether you go through withRateLimit/getClientIp or call rateLimit().check({ ip }) directly. Not recognized as IPs: the rare non-dotted IPv4 encodings (decimal 2130706433, hex 0x7f000001, octal 0177.0.0.1). isIP rejects them, so they fall to the unkeyable path — denied by default, and allowed if you set onUnkeyable: 'allow'. A client cannot use them to mint an extra bucket under the default; under allow they are simply not limited, like any other unkeyable request.

  • Memory is bounded by key COUNT, not by bytes. The store is an in-memory LRU capped at maxKeys (default 100k) — and there are two of them, a user bucket and an IP bucket, so the real ceiling is 2 × maxKeys keys. A flood of spoofed IPs can't exhaust it. Key size is capped separately: a key longer than 256 characters is not treated as an identity (a UUID is 36, a canonical IPv6 is 39), because the LRU alone would happily hold 100k keys of any length. withRateLimit also sweeps idle buckets every ~1000 requests. Trade-off of the cap: under a flood of distinct keys large enough to evict an in-use key, that key's bucket is dropped and its counter resets on the next hit — the memory bound is chosen over a perfectly durable counter. Raise maxKeys, or use the Pro tier's hosted store, if you need the counter to survive key churn at that scale. Per instance — for multi-region, share one store (the Pro tier adds a hosted store + an abuse dashboard).

  • X-RateLimit-Reset is token-bucket semantics, and means different things on the two paths. On an allowed (2xx) response it is seconds until the bucket is full again — on a request that spent 1 of 5 it reads a small non-zero value even with tokens to spare. On a denied (429) response it is seconds until the next single token is available, i.e. it equals Retry-After, not the time to refill completely. Both are correct for a token bucket, but if you schedule a retry off the 429's Reset expecting a full refill, you'll retry early. Use Retry-After to retry; if you need fixed-window "quota resets at" semantics, derive them yourself. (The one number a client always wants — when may I try again — is Retry-After, on both header families.)


Part of ShipSealed — ship apps that don't leak. MIT licensed.

Keywords