diff options
| author | Fuwn <[email protected]> | 2026-06-02 01:56:02 +0000 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-06-02 01:56:02 +0000 |
| commit | 6dc809d833212a1b5ff75786bfacfb4581dac1a2 (patch) | |
| tree | 598fd32eef9eba6047ee4973912666aac77ff729 /src/lib/Error/rateLimit.ts | |
| parent | fix(security): sanitize badge_wall_css server-side, render via textContent (diff) | |
| download | due.moe-6dc809d833212a1b5ff75786bfacfb4581dac1a2.tar.xz due.moe-6dc809d833212a1b5ff75786bfacfb4581dac1a2.zip | |
fix(security): make rate limiting real; limit the click counter (L8/L10)
rateLimit.ts built a new in-memory RateLimiter inside the function on every
call, so its store was always empty and it never limited anything. Rewrite
as module-level singletons: auth/mutation/read IP classes returning 429,
plus a click-counter limiter keyed on (IP, badge) so a viewer browsing many
different badges isn't throttled while hammering one badge is capped. Wire
the counter into PUT /api/badges?incrementClickCount (L10). Add a pluggable
RateLimiterStore seam (in-memory default, Upstash/Vercel KV ready) and
document the serverless per-region caveat.
Add docs/vercel-firewall.md with the dashboard WAF rate-limit rule spec
(Hobby 1-rule vs Pro) for coarse per-IP edge protection — keys the app
limiters can't express at the edge.
Verified locally: same-badge hits 200,200,429,429…; a different badge stays
200; a no-origin hit is rate-checked then 401.
Diffstat (limited to 'src/lib/Error/rateLimit.ts')
| -rw-r--r-- | src/lib/Error/rateLimit.ts | 64 |
1 files changed, 57 insertions, 7 deletions
diff --git a/src/lib/Error/rateLimit.ts b/src/lib/Error/rateLimit.ts index 636bdd5d..d3508aba 100644 --- a/src/lib/Error/rateLimit.ts +++ b/src/lib/Error/rateLimit.ts @@ -1,12 +1,62 @@ import type { RequestEvent } from "@sveltejs/kit"; -import { RateLimiter } from "sveltekit-rate-limiter/server"; +import { + RateLimiter, + type RateLimiterStore, +} from "sveltekit-rate-limiter/server"; -export const checkRateLimit = async (event: RequestEvent) => { - const limiter = new RateLimiter({ rates: { IP: [5, "s"] } }); +// Storage. The library's default store is in-memory and PER INSTANCE. On +// Vercel's serverless runtime, counters do not aggregate across lambda +// instances or regions, so these limiters are best-effort backstops there. +// Provision a shared store (Upstash Redis / Vercel KV) implementing +// RateLimiterStore and return it here for durable limits. Coarse per-IP abuse +// protection also runs at the edge via the Vercel WAF (see +// docs/vercel-firewall.md); these app-level limiters add what the WAF cannot +// express — per-user windows and the per-(IP, badge) click counter. +const createStore = (): RateLimiterStore | undefined => { + // e.g. `if (env.UPSTASH_REDIS_REST_URL) return new UpstashStore(...)` + return undefined; +}; - await limiter.cookieLimiter?.preflight(event); +const store = createStore(); - if (await limiter.isLimited(event)) return new Response("rate-limited"); +// Module-level singletons: the counters must persist across requests. (The +// previous implementation built a new RateLimiter on every call, so its store +// was always empty and it never actually limited anything.) +const limiters = { + auth: new RateLimiter({ IP: [10, "m"], store }), + mutation: new RateLimiter({ IP: [30, "m"], store }), + read: new RateLimiter({ IP: [60, "m"], store }), +} satisfies Record<string, RateLimiter>; - return null; -}; +export type RateLimitClass = keyof typeof limiters; + +const tooManyRequests = () => + new Response("Too Many Requests", { status: 429 }); + +export const checkRateLimit = async ( + event: RequestEvent, + limit: RateLimitClass = "mutation", +): Promise<Response | null> => + (await limiters[limit].isLimited(event)) ? tooManyRequests() : null; + +// Click counter: bound inflation of a single badge without throttling a viewer +// browsing many DIFFERENT badges. Keyed on (IP, badge) — which the edge WAF +// can't express, since it keys on IP alone. +const clickCounterLimiter = new RateLimiter({ + store, + plugins: [ + { + rate: [2, "m"], + hash: async (event) => { + const badgeId = event.url.searchParams.get("incrementClickCount"); + + return badgeId ? `click:${event.getClientAddress()}:${badgeId}` : true; + }, + }, + ], +}); + +export const checkClickCounterLimit = async ( + event: RequestEvent, +): Promise<Response | null> => + (await clickCounterLimiter.isLimited(event)) ? tooManyRequests() : null; |