aboutsummaryrefslogtreecommitdiff
path: root/src/routes/api/badges/+server.ts
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-06-02 01:56:02 +0000
committerFuwn <[email protected]>2026-06-02 01:56:02 +0000
commit6dc809d833212a1b5ff75786bfacfb4581dac1a2 (patch)
tree598fd32eef9eba6047ee4973912666aac77ff729 /src/routes/api/badges/+server.ts
parentfix(security): sanitize badge_wall_css server-side, render via textContent (diff)
downloaddue.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/routes/api/badges/+server.ts')
-rw-r--r--src/routes/api/badges/+server.ts9
1 files changed, 8 insertions, 1 deletions
diff --git a/src/routes/api/badges/+server.ts b/src/routes/api/badges/+server.ts
index 10b63125..a4212f40 100644
--- a/src/routes/api/badges/+server.ts
+++ b/src/routes/api/badges/+server.ts
@@ -15,6 +15,7 @@ import {
} from "$lib/Database/SB/User/badges";
import { decodeAuthCookieOrNull } from "$lib/Effect/authCookie";
import { decodeRequestJsonOrThrow } from "$lib/Effect/requestBody";
+import { checkClickCounterLimit } from "$lib/Error/rateLimit";
import { appOrigin, appOriginHeaders } from "$lib/Utility/appOrigin";
import { isOwnerOrPrivileged } from "$lib/Utility/authorisation";
import privilegedUser from "$lib/Utility/privilegedUser";
@@ -53,8 +54,14 @@ export const DELETE = async ({ url, cookies }) => {
return await badges(identity.id);
};
-export const PUT = async ({ cookies, url, request }) => {
+export const PUT = async (event) => {
+ const { cookies, url, request } = event;
+
if (url.searchParams.get("incrementClickCount") || undefined) {
+ const limited = await checkClickCounterLimit(event);
+
+ if (limited) return limited;
+
if (request.headers.get("origin") !== appOrigin()) return unauthorised();
await incrementClickCount(