From 45ac86a924a93893e0de88fa8a0a539567942af0 Mon Sep 17 00:00:00 2001 From: valeboth <109695782+valeboth@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:14:51 +0300 Subject: [PATCH] feat: minimal rate-limiting on public write endpoints - lib/ratelimit.ts: fixed-window KV limiter (per-IP, fails open on KV errors; no write once over the limit so writes stay bounded) - POST /api/users -> 20/min/IP, POST /api/rooms -> 30/min/IP; over limit -> 429 - health -> v3.6 Tested local: 21st user-create in a minute returns 429. --- src/index.ts | 2 +- src/lib/ratelimit.ts | 30 ++++++++++++++++++++++++++++++ src/routes/rooms.ts | 4 ++++ src/routes/users.ts | 4 ++++ 4 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 src/lib/ratelimit.ts diff --git a/src/index.ts b/src/index.ts index 384c96f..ba19fa6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,7 +10,7 @@ export { Room } from "./durable-objects/room"; const app = new Hono<{ Bindings: Env }>(); -app.get("/api/health", (c) => c.json({ ok: true, service: "cinemate", version: "v3.5" })); +app.get("/api/health", (c) => c.json({ ok: true, service: "cinemate", version: "v3.6" })); app.route("/api/users", users); app.route("/api/profile", profile); diff --git a/src/lib/ratelimit.ts b/src/lib/ratelimit.ts new file mode 100644 index 0000000..bee3525 --- /dev/null +++ b/src/lib/ratelimit.ts @@ -0,0 +1,30 @@ +// Minimal fixed-window rate limiter backed by KV. Best-effort (fails open on KV errors), +// good enough to stop casual spam of the public write endpoints (create user / room). +// For serious abuse, Cloudflare WAF rate-limiting rules are the proper tool. + +import type { Env } from "../types"; + +/** Returns true if the action is allowed, false if over the limit for this window. */ +export async function rateLimit( + env: Env, + id: string, + limit: number, + windowSec: number, +): Promise { + const bucket = Math.floor(Date.now() / (windowSec * 1000)); + const key = `rl:${id}:${bucket}`; + try { + const current = await env.TMDB_CACHE.get(key); + const count = current ? Number(current) : 0; + if (count >= limit) return false; // over limit → no write, so writes stay bounded + await env.TMDB_CACHE.put(key, String(count + 1), { expirationTtl: windowSec * 2 }); + return true; + } catch { + return true; // fail open — never block real users on a KV hiccup + } +} + +/** Client IP (Cloudflare-provided), for keying the limiter. */ +export function clientIp(headers: Headers): string { + return headers.get("CF-Connecting-IP") || "anon"; +} diff --git a/src/routes/rooms.ts b/src/routes/rooms.ts index 67d6d8c..401f756 100644 --- a/src/routes/rooms.ts +++ b/src/routes/rooms.ts @@ -7,12 +7,16 @@ import { getDeckForUser, getCardFromDeck, resetDeck, buildMatchReason } from ".. import { getWatchProviders, getImdbId, getTrailerKey } from "../services/tmdb"; import { getOmdbRatings } from "../services/omdb"; import { createRequest } from "../services/overseerr"; +import { rateLimit, clientIp } from "../lib/ratelimit"; export const rooms = new Hono<{ Bindings: Env }>(); // POST /api/rooms — create a room + invite code. The user becomes user_a. // Body: { user_id, media_type?='movie', platform_filter?, solo?=false } rooms.post("/", async (c) => { + if (!(await rateLimit(c.env, `rooms:${clientIp(c.req.raw.headers)}`, 30, 60))) { + return c.json({ error: "rate_limited" }, 429); + } const body = await c.req.json().catch(() => null); const userId = typeof body?.user_id === "string" ? body.user_id : ""; if (!userId) return c.json({ error: "user_id_required" }, 400); diff --git a/src/routes/users.ts b/src/routes/users.ts index d759322..fa88070 100644 --- a/src/routes/users.ts +++ b/src/routes/users.ts @@ -3,12 +3,16 @@ import type { Env, MediaType } from "../types"; import { genId } from "../lib/ids"; import { getTitleCard } from "../services/tmdb"; import { createRequest } from "../services/overseerr"; +import { rateLimit, clientIp } from "../lib/ratelimit"; export const users = new Hono<{ Bindings: Env }>(); // POST /api/users — create a user. // Body: { username: string } users.post("/", async (c) => { + if (!(await rateLimit(c.env, `users:${clientIp(c.req.raw.headers)}`, 20, 60))) { + return c.json({ error: "rate_limited" }, 429); + } const body = await c.req.json().catch(() => null); const username = typeof body?.username === "string" ? body.username.trim() : ""; if (username.length < 1 || username.length > 40) {