From 7623d4ae1f1c0fa36d670e8bb520795dd96b4944 Mon Sep 17 00:00:00 2001 From: Josh Payette Date: Fri, 3 Jul 2026 21:46:39 -0400 Subject: [PATCH] Added rate limiting on api endpoints --- package.json | 1 + pnpm-lock.yaml | 8 ++++ .../favorite-games/favorite-games.server.ts | 9 ++-- .../data/user-profile/user-profile.server.ts | 8 +++- .../sync/local-data/apply-pending-ops.ts | 2 + src/features/theme/SyncAndApplyTheme.ts | 4 +- .../data/server/collected-items.server.ts | 3 ++ .../data/server/collected-items.server.ts | 3 ++ .../data/server/created-builds.server.ts | 9 +++- .../data/server/collected-items.server.ts | 3 ++ src/integrations/better-auth/auth.ts | 12 ++++++ .../user-write-limit.server.ts | 41 +++++++++++++++++++ 12 files changed, 91 insertions(+), 12 deletions(-) create mode 100644 src/integrations/rate-limiter-flexible/user-write-limit.server.ts diff --git a/package.json b/package.json index b421fc4..fd4c7c7 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "pino": "10.3.1", "pino-pretty": "13.1.3", "prisma": "7.8.0", + "rate-limiter-flexible": "^11.2.0", "react": "19.2.7", "react-dom": "19.2.7", "react-icons": "5.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 239e7cb..57a2620 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -143,6 +143,9 @@ importers: prisma: specifier: 7.8.0 version: 7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3) + rate-limiter-flexible: + specifier: ^11.2.0 + version: 11.2.0 react: specifier: 19.2.7 version: 19.2.7 @@ -4724,6 +4727,9 @@ packages: quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + rate-limiter-flexible@11.2.0: + resolution: {integrity: sha512-L0eIK+BmFMi6NcvmtEg7RSswOFKi9MMD5RhBIypFfOve+G6Jl1Xbb8qEHgK3uRzNtkOFYF0L9f7P4rSf2PnUVw==} + rc9@3.0.1: resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} @@ -9831,6 +9837,8 @@ snapshots: quick-format-unescaped@4.0.4: {} + rate-limiter-flexible@11.2.0: {} + rc9@3.0.1: dependencies: defu: 6.1.7 diff --git a/src/features/game/data/favorite-games/favorite-games.server.ts b/src/features/game/data/favorite-games/favorite-games.server.ts index 7507d6c..b0616ef 100644 --- a/src/features/game/data/favorite-games/favorite-games.server.ts +++ b/src/features/game/data/favorite-games/favorite-games.server.ts @@ -1,14 +1,10 @@ -// Server-only data access for the userFavoriteGame entity (a presence toggle). -// The `.server.ts` suffix opts this file into Start's import protection, so -// prisma can never be pulled into the client bundle. Consumed by the -// favorite-games server-fn wrappers (inside their handlers). The offline-sync -// handler lives alongside in favorite-games.sync.server.ts. - import { requireUserId } from "#/features/user/require-user.server.ts"; +import { enforceUserWriteLimit } from "#/integrations/rate-limiter-flexible/user-write-limit.server.ts"; import { type GameId, prisma } from "@/prisma"; const favoriteGame = async (gameId: GameId) => { const userId = await requireUserId(); + await enforceUserWriteLimit(userId); return prisma.userFavoriteGame.upsert({ where: { userId_gameId: { userId, gameId } }, update: {}, @@ -18,6 +14,7 @@ const favoriteGame = async (gameId: GameId) => { const unfavoriteGame = async (gameId: GameId) => { const userId = await requireUserId(); + await enforceUserWriteLimit(userId); await prisma.userFavoriteGame.deleteMany({ where: { userId, gameId } }); return { ok: true as const }; }; diff --git a/src/features/game/data/user-profile/user-profile.server.ts b/src/features/game/data/user-profile/user-profile.server.ts index dadab9d..743bb71 100644 --- a/src/features/game/data/user-profile/user-profile.server.ts +++ b/src/features/game/data/user-profile/user-profile.server.ts @@ -3,7 +3,9 @@ import { getOptionalUserId, requireUserId, } from "#/features/user/require-user.server.ts"; -import { getGameAvatars } from "#/registry/game-public-registry.tsx";; +import { enforceUserWriteLimit } from "#/integrations/rate-limiter-flexible/user-write-limit.server.ts"; +import { getGameAvatars } from "#/registry/game-public-registry.tsx"; + import { type GameId, prisma } from "@/prisma"; type UpdateAvatarData = { @@ -14,6 +16,7 @@ type UpdateAvatarData = { const updateAvatar = async (data: UpdateAvatarData) => { const userId = await requireUserId(); + await enforceUserWriteLimit(userId); const avatars = getGameAvatars(data.avatarGameId); const avatarExists = avatars?.some((a) => a.id === data.avatarId); @@ -56,6 +59,7 @@ const updateAvatar = async (data: UpdateAvatarData) => { const removePrimaryAvatar = async () => { const userId = await requireUserId(); + await enforceUserWriteLimit(userId); await prisma.userProfile.update({ where: { userId }, data: { primaryAvatarId: null, primaryAvatarGameId: null }, @@ -65,6 +69,7 @@ const removePrimaryAvatar = async () => { const removeAvatarOverride = async (targetGameId: GameId) => { const userId = await requireUserId(); + await enforceUserWriteLimit(userId); const profile = await prisma.userProfile.findUnique({ where: { userId } }); if (!profile) throw new Error("User profile not found"); @@ -76,6 +81,7 @@ const removeAvatarOverride = async (targetGameId: GameId) => { const updateProfile = async (data: { displayName?: string; bio?: string }) => { const userId = await requireUserId(); + await enforceUserWriteLimit(userId); await prisma.userProfile.update({ where: { userId }, data: { diff --git a/src/features/sync/local-data/apply-pending-ops.ts b/src/features/sync/local-data/apply-pending-ops.ts index d4cb734..c5840c3 100644 --- a/src/features/sync/local-data/apply-pending-ops.ts +++ b/src/features/sync/local-data/apply-pending-ops.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import type { SyncResult } from "#/features/sync/local-data/types.ts"; import { requireUserId } from "#/features/user/require-user.server.ts"; import { syncHandlers } from "#/game-registry/handler-registry.server.ts"; +import { enforceUserWriteLimit } from "#/integrations/rate-limiter-flexible/user-write-limit.server.ts"; /** Re-validates the PendingOp at the server boundary; client data is untrusted. */ const PendingOpSchema = z.object({ @@ -61,6 +62,7 @@ const applyPendingOpServerFn = createServerFn({ method: "POST" }) const { op, force } = data; // requireUserId() runs first because userId is part of the deduplication key. const userId = await requireUserId(); + await enforceUserWriteLimit(userId); // Force-syncs must skip the dedupe cache: the prior cached result is the // conflict the user just chose to override. const dedupeKey = `${userId}:${op.idempotencyKey}`; diff --git a/src/features/theme/SyncAndApplyTheme.ts b/src/features/theme/SyncAndApplyTheme.ts index b0c4efd..10bd7e1 100644 --- a/src/features/theme/SyncAndApplyTheme.ts +++ b/src/features/theme/SyncAndApplyTheme.ts @@ -27,7 +27,7 @@ const nextThemeToMantineTheme = ( return themeDefinition.theme; }; -const SyncAndApplyTheme = () => { +export const SyncAndApplyTheme = () => { const { theme: nextTheme, setTheme: setNextTheme } = useNextTheme(); const gameId = useGameId(); @@ -80,5 +80,3 @@ const SyncAndApplyTheme = () => { return null; }; - -export { SyncAndApplyTheme }; diff --git a/src/games/clairobscur/data/server/collected-items.server.ts b/src/games/clairobscur/data/server/collected-items.server.ts index 3d73e06..6420a40 100644 --- a/src/games/clairobscur/data/server/collected-items.server.ts +++ b/src/games/clairobscur/data/server/collected-items.server.ts @@ -1,9 +1,11 @@ import type { CollectedItemRecord } from "#/features/game/data/types.ts"; import { requireUserId } from "#/features/user/require-user.server.ts"; +import { enforceUserWriteLimit } from "#/integrations/rate-limiter-flexible/user-write-limit.server.ts"; import { prisma } from "@/prisma"; const collectItem = async (itemId: string): Promise => { const userId = await requireUserId(); + await enforceUserWriteLimit(userId); return prisma.clairObscurCollectedItem.upsert({ where: { userId_itemId: { userId, itemId } }, update: {}, @@ -13,6 +15,7 @@ const collectItem = async (itemId: string): Promise => { const uncollectItem = async (itemId: string) => { const userId = await requireUserId(); + await enforceUserWriteLimit(userId); await prisma.clairObscurCollectedItem.deleteMany({ where: { userId, itemId }, }); diff --git a/src/games/remnant2/data/server/collected-items.server.ts b/src/games/remnant2/data/server/collected-items.server.ts index 6ed3229..e37b170 100644 --- a/src/games/remnant2/data/server/collected-items.server.ts +++ b/src/games/remnant2/data/server/collected-items.server.ts @@ -1,9 +1,11 @@ import type { CollectedItemRecord } from "#/features/game/data/types.ts"; import { requireUserId } from "#/features/user/require-user.server.ts"; +import { enforceUserWriteLimit } from "#/integrations/rate-limiter-flexible/user-write-limit.server.ts"; import { prisma } from "@/prisma"; const collectItem = async (itemId: string): Promise => { const userId = await requireUserId(); + await enforceUserWriteLimit(userId); return prisma.remnant2CollectedItem.upsert({ where: { userId_itemId: { userId, itemId } }, update: {}, @@ -13,6 +15,7 @@ const collectItem = async (itemId: string): Promise => { const uncollectItem = async (itemId: string) => { const userId = await requireUserId(); + await enforceUserWriteLimit(userId); await prisma.remnant2CollectedItem.deleteMany({ where: { userId, itemId } }); return { ok: true as const }; }; diff --git a/src/games/remnant2/data/server/created-builds.server.ts b/src/games/remnant2/data/server/created-builds.server.ts index a3041a3..edddb4b 100644 --- a/src/games/remnant2/data/server/created-builds.server.ts +++ b/src/games/remnant2/data/server/created-builds.server.ts @@ -4,6 +4,7 @@ import type { CreatedBuildSummary, } from "#/features/game/data/types.ts"; import { requireUserId } from "#/features/user/require-user.server.ts"; +import { enforceUserWriteLimit } from "#/integrations/rate-limiter-flexible/user-write-limit.server.ts"; import { BuildVisibility, prisma } from "@/prisma"; /** Server-side build-write fields — like BuildWriteFields but with the real enum. */ @@ -50,11 +51,15 @@ const listBuildsByUserId = (userId: string): Promise => const updateBuild = async ( buildId: string, fields: BuildWriteFields, -): Promise => - updateOwnedBuild(await requireUserId(), buildId, fields); +): Promise => { + const userId = await requireUserId(); + await enforceUserWriteLimit(userId); + return updateOwnedBuild(userId, buildId, fields); +}; const deleteBuild = async (buildId: string) => { const userId = await requireUserId(); + await enforceUserWriteLimit(userId); await prisma.remnant2Build.deleteMany({ where: { id: buildId, createdById: userId }, }); diff --git a/src/games/slaythespire2/data/server/collected-items.server.ts b/src/games/slaythespire2/data/server/collected-items.server.ts index 50bd1be..9158dd7 100644 --- a/src/games/slaythespire2/data/server/collected-items.server.ts +++ b/src/games/slaythespire2/data/server/collected-items.server.ts @@ -1,9 +1,11 @@ import type { CollectedItemRecord } from "#/features/game/data/types.ts"; import { requireUserId } from "#/features/user/require-user.server.ts"; +import { enforceUserWriteLimit } from "#/integrations/rate-limiter-flexible/user-write-limit.server.ts"; import { prisma } from "@/prisma"; const collectItem = async (itemId: string): Promise => { const userId = await requireUserId(); + await enforceUserWriteLimit(userId); return prisma.slayTheSpire2CollectedItem.upsert({ where: { userId_itemId: { userId, itemId } }, update: {}, @@ -13,6 +15,7 @@ const collectItem = async (itemId: string): Promise => { const uncollectItem = async (itemId: string) => { const userId = await requireUserId(); + await enforceUserWriteLimit(userId); await prisma.slayTheSpire2CollectedItem.deleteMany({ where: { userId, itemId }, }); diff --git a/src/integrations/better-auth/auth.ts b/src/integrations/better-auth/auth.ts index 3b6f26e..88ff103 100644 --- a/src/integrations/better-auth/auth.ts +++ b/src/integrations/better-auth/auth.ts @@ -58,6 +58,18 @@ const auth = betterAuth({ maxAge: 5 * 60, // 5 minutes }, }, + rateLimit: { + window: 60, // default window, in seconds + max: 100, // default requests per window per IP + storage: "memory", + customRules: { + "/sign-in/email": { window: 60, max: 10 }, + "/sign-up/email": { window: 60, max: 5 }, + "/forget-password": { window: 60, max: 3 }, + "/reset-password": { window: 60, max: 5 }, + "/send-verification-email": { window: 60, max: 3 }, + }, + }, user: { additionalFields: { username: { diff --git a/src/integrations/rate-limiter-flexible/user-write-limit.server.ts b/src/integrations/rate-limiter-flexible/user-write-limit.server.ts new file mode 100644 index 0000000..96d2e5b --- /dev/null +++ b/src/integrations/rate-limiter-flexible/user-write-limit.server.ts @@ -0,0 +1,41 @@ +import { RateLimiterMemory, type RateLimiterRes } from "rate-limiter-flexible"; + +/** + * In-memory, per-process rate limiter for authenticated writes, keyed by userId. + * + * We key by userId (never IP) because every guarded call has already passed + * requireUserId(), and a userId can't be spoofed the way a forwarded IP header + * can. + */ +const writeLimiter = new RateLimiterMemory({ + points: 60, // allow up to 60 writes... + duration: 60, // ...per rolling 60-second window, per user +}); + +/** + * Consumes one point for `userId`, throwing a 429 Response when the user is over + * budget. Thrown Responses propagate as the HTTP result (this mirrors how + * requireUserId() throws a 401 Response), so call this right after resolving the + * userId in a write server function: + * + * const userId = await requireUserId(); + * await enforceUserWriteLimit(userId); + */ +const enforceUserWriteLimit = async (userId: string): Promise => { + try { + await writeLimiter.consume(userId); + } catch (rejection) { + // consume() rejects with a RateLimiterRes when rate-limited; a real Error + // means an unexpected internal failure, which we surface rather than mask + // as a 429. + if (rejection instanceof Error) throw rejection; + const res = rejection as RateLimiterRes; + const retryAfterSec = Math.ceil((res.msBeforeNext ?? 1000) / 1000); + throw new Response("Too Many Requests", { + status: 429, + headers: { "Retry-After": String(retryAfterSec) }, + }); + } +}; + +export { enforceUserWriteLimit }; -- 2.51.2