From a0a10001d5c3f1521c257175ff7bbbd70fa4178f Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 13:48:59 +0200 Subject: [PATCH] feat(rate-limit): per-user mutation-rate-limiting (v1-readiness #3) lib/rate-limit.ts: 11 nieuwe scope-configs + enforceUserRateLimit(scope, userId) helper. Returnt { error, code: 429 } shape voor consistent foutbeleid. Toegepast op de high-value mutation-paths: - actions/pbis.ts createPbiAction - actions/stories.ts createStoryAction - actions/tasks.ts saveTask (alleen create-path) + createTaskAction - actions/todos.ts createTodoAction - actions/sprints.ts createSprintAction - actions/products.ts createProductAction + createProductFormAction - actions/api-tokens.ts createApiTokenAction - actions/questions.ts answerQuestion - actions/claude-jobs.ts enqueueClaudeJobAction + enqueueClaudeJobsBatchAction - app/api/profile/avatar/route.ts POST - app/api/stories/[id]/log/route.ts POST Limits zijn ruim genoeg voor normaal gebruik, eng genoeg voor abuse-loops: create-task 100/min, create-todo 60/min, create-pbi 30/min, create-product 5/min, create-token 10/uur, etc. Per-user scope (geen globale block). Niet aangeraakt: reorder/status-toggle (intra-session frequent, lage abuse), update/delete (laag-volume), cron-routes (CRON_SECRET-gated). Consumer-tweaks: 'success' in result narrowing waar TS de bredere union niet meer accepteerde. Tests: 9 nieuwe op rate-limit-helper. Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/lib/rate-limit.test.ts | 64 +++++++++++++++++++++++++++ actions/api-tokens.ts | 4 ++ actions/claude-jobs.ts | 7 +++ actions/pbis.ts | 4 ++ actions/products.ts | 7 +++ actions/questions.ts | 4 ++ actions/sprints.ts | 4 ++ actions/stories.ts | 4 ++ actions/tasks.ts | 11 +++++ actions/todos.ts | 4 ++ app/api/profile/avatar/route.ts | 4 ++ app/api/stories/[id]/log/route.ts | 4 ++ components/settings/token-manager.tsx | 2 +- components/todos/todo-list.tsx | 4 +- docs/plans/v1-readiness.md | 17 +++---- lib/rate-limit.ts | 44 ++++++++++++++++++ 16 files changed, 175 insertions(+), 13 deletions(-) create mode 100644 __tests__/lib/rate-limit.test.ts diff --git a/__tests__/lib/rate-limit.test.ts b/__tests__/lib/rate-limit.test.ts new file mode 100644 index 0000000..aa9c636 --- /dev/null +++ b/__tests__/lib/rate-limit.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { checkRateLimit, enforceUserRateLimit, _resetRateLimit } from '@/lib/rate-limit' + +beforeEach(() => { + _resetRateLimit() +}) + +describe('checkRateLimit (legacy auth-keys)', () => { + it('staat de eerste request toe', () => { + expect(checkRateLimit('login:1.2.3.4')).toBe(true) + }) + + it('blokkeert na exceeding max (login: 10/min)', () => { + for (let i = 0; i < 10; i++) checkRateLimit('login:1.2.3.4') + expect(checkRateLimit('login:1.2.3.4')).toBe(false) + }) + + it('register heeft eigen lagere limiet (5/uur)', () => { + for (let i = 0; i < 5; i++) checkRateLimit('register:9.9.9.9') + expect(checkRateLimit('register:9.9.9.9')).toBe(false) + }) + + it('verschillende keys hebben hun eigen counter', () => { + for (let i = 0; i < 10; i++) checkRateLimit('login:1.1.1.1') + expect(checkRateLimit('login:1.1.1.1')).toBe(false) + expect(checkRateLimit('login:2.2.2.2')).toBe(true) + }) +}) + +describe('enforceUserRateLimit (v1-readiness #3 mutation-scopes)', () => { + it('returnt null bij eerste call', () => { + expect(enforceUserRateLimit('create-pbi', 'user-1')).toBeNull() + }) + + it('returnt 429-shape na exceeding limiet', () => { + // create-product limiet = 5/min + for (let i = 0; i < 5; i++) enforceUserRateLimit('create-product', 'user-1') + const result = enforceUserRateLimit('create-product', 'user-1') + expect(result).not.toBeNull() + expect(result?.code).toBe(429) + expect(result?.error).toContain('Te veel acties') + }) + + it('scope is per (action, user) — andere user heeft eigen quota', () => { + for (let i = 0; i < 5; i++) enforceUserRateLimit('create-product', 'user-A') + expect(enforceUserRateLimit('create-product', 'user-A')).not.toBeNull() + expect(enforceUserRateLimit('create-product', 'user-B')).toBeNull() + }) + + it('verschillende scopes voor dezelfde user vullen apart', () => { + for (let i = 0; i < 5; i++) enforceUserRateLimit('create-product', 'user-1') + expect(enforceUserRateLimit('create-product', 'user-1')).not.toBeNull() + // create-task heeft eigen counter + expect(enforceUserRateLimit('create-task', 'user-1')).toBeNull() + }) + + it('create-task limiet (100) is hoger dan create-pbi (30)', () => { + for (let i = 0; i < 30; i++) enforceUserRateLimit('create-pbi', 'u') + expect(enforceUserRateLimit('create-pbi', 'u')).not.toBeNull() + // create-task is nog niet hit + for (let i = 0; i < 30; i++) enforceUserRateLimit('create-task', 'u') + expect(enforceUserRateLimit('create-task', 'u')).toBeNull() + }) +}) diff --git a/actions/api-tokens.ts b/actions/api-tokens.ts index 3964342..136692b 100644 --- a/actions/api-tokens.ts +++ b/actions/api-tokens.ts @@ -6,6 +6,7 @@ import { getIronSession } from 'iron-session' import { createHash, randomBytes } from 'crypto' import { prisma } from '@/lib/prisma' import { SessionData, sessionOptions } from '@/lib/session' +import { enforceUserRateLimit } from '@/lib/rate-limit' async function getSession() { return getIronSession(await cookies(), sessionOptions) @@ -16,6 +17,9 @@ export async function createApiTokenAction(_prevState: unknown, formData: FormDa if (!session.userId) return { error: 'Niet ingelogd' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + const limited = enforceUserRateLimit('create-token', session.userId) + if (limited) return limited + const label = (formData.get('label') as string | null)?.trim() || null // Max 10 active tokens diff --git a/actions/claude-jobs.ts b/actions/claude-jobs.ts index d7ba1e3..f39c8ff 100644 --- a/actions/claude-jobs.ts +++ b/actions/claude-jobs.ts @@ -5,6 +5,7 @@ import { prisma } from '@/lib/prisma' import { getSession } from '@/lib/auth' import { productAccessFilter } from '@/lib/product-access' import { ACTIVE_JOB_STATUSES, jobStatusToApi } from '@/lib/job-status' +import { enforceUserRateLimit } from '@/lib/rate-limit' type EnqueueResult = | { success: true; jobId: string } @@ -34,6 +35,9 @@ export async function enqueueClaudeJobAction(taskId: string): Promise(await cookies(), sessionOptions) @@ -22,6 +23,9 @@ export async function createPbiAction(_prevState: unknown, formData: FormData) { if (!session.userId) return { error: 'Niet ingelogd', code: 403 } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } + const limited = enforceUserRateLimit('create-pbi', session.userId) + if (limited) return limited + const parsed = createPbiSchema.safeParse({ productId: formData.get('productId'), code: (formData.get('code') as string) || undefined, diff --git a/actions/products.ts b/actions/products.ts index 7024c3b..f238058 100644 --- a/actions/products.ts +++ b/actions/products.ts @@ -11,6 +11,7 @@ import { Role } from '@prisma/client' import { isValidCode, MAX_CODE_LENGTH, normalizeCode } from '@/lib/code' import { productAccessFilter } from '@/lib/product-access' import { productSchema as productInput, type ProductInput } from '@/lib/schemas/product' +import { enforceUserRateLimit } from '@/lib/rate-limit' // Legacy FormData schema for ProductForm components (other constraints than dialog) const productFormDataSchema = z.object({ @@ -49,6 +50,9 @@ export async function createProductAction(data: ProductInput): Promise(await cookies(), sessionOptions) @@ -27,6 +28,9 @@ export async function createSprintAction(_prevState: unknown, formData: FormData if (!session.userId) return { error: 'Niet ingelogd', code: 403 } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } + const limited = enforceUserRateLimit('create-sprint', session.userId) + if (limited) return limited + const parsed = createSprintSchema.safeParse({ productId: formData.get('productId'), sprint_goal: formData.get('sprint_goal'), diff --git a/actions/stories.ts b/actions/stories.ts index d980f56..b66ec01 100644 --- a/actions/stories.ts +++ b/actions/stories.ts @@ -10,6 +10,7 @@ import { requireProductWriter } from '@/lib/auth' import { isValidCode, normalizeCode } from '@/lib/code' import { createWithCodeRetry, generateNextStoryCode } from '@/lib/code-server' import { createStorySchema, updateStorySchema } from '@/lib/schemas/story' +import { enforceUserRateLimit } from '@/lib/rate-limit' async function getSession() { return getIronSession(await cookies(), sessionOptions) @@ -33,6 +34,9 @@ export async function createStoryAction(_prevState: unknown, formData: FormData) if (!session.userId) return { error: 'Niet ingelogd', code: 403 } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } + const limited = enforceUserRateLimit('create-story', session.userId) + if (limited) return limited + const parsed = createStorySchema.safeParse({ pbiId: formData.get('pbiId'), productId: formData.get('productId'), diff --git a/actions/tasks.ts b/actions/tasks.ts index d9d1080..c0210a6 100644 --- a/actions/tasks.ts +++ b/actions/tasks.ts @@ -12,6 +12,7 @@ import { taskSchema as sharedTaskSchema, type TaskInput } from '@/lib/schemas/ta import { updateTaskStatusWithStoryPromotion } from '@/lib/tasks-status-update' import { normalizeCode } from '@/lib/code' import { createWithCodeRetry, generateNextTaskCode, isCodeUniqueConflict } from '@/lib/code-server' +import { enforceUserRateLimit } from '@/lib/rate-limit' async function getSession() { return getIronSession(await cookies(), sessionOptions) @@ -22,6 +23,7 @@ export type SaveTaskResult = | { ok: true; task: { id: string; title: string; status: string } } | { ok: false; code: 422; error: 'validation'; fieldErrors: Record } | { ok: false; code: 403; error: 'demo_readonly' | 'forbidden' } + | { ok: false; code: 429; error: 'rate_limited' } | { ok: false; code: 500; error: 'server_error' } export type DeleteTaskResult = @@ -39,6 +41,12 @@ export async function saveTask( if (!session.userId) return { ok: false, code: 403, error: 'forbidden' } if (session.isDemo) return { ok: false, code: 403, error: 'demo_readonly' } + // Rate-limit alleen op create-path; edits zijn laag-volume. + if (!context.taskId) { + const limited = enforceUserRateLimit('create-task', session.userId) + if (limited) return { ok: false, code: 429, error: 'rate_limited' } + } + const parsed = sharedTaskSchema.safeParse(input) if (!parsed.success) { return { @@ -181,6 +189,9 @@ export async function createTaskAction(_prevState: unknown, formData: FormData) if (!session.userId) return { error: 'Niet ingelogd' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + const limited = enforceUserRateLimit('create-task', session.userId) + if (limited) return limited + const storyId = formData.get('storyId') as string const sprintId = formData.get('sprintId') as string diff --git a/actions/todos.ts b/actions/todos.ts index 04e3fae..3c68da9 100644 --- a/actions/todos.ts +++ b/actions/todos.ts @@ -8,6 +8,7 @@ import { prisma } from '@/lib/prisma' import { SessionData, sessionOptions } from '@/lib/session' import { productAccessFilter } from '@/lib/product-access' import { generateNextPbiCode, generateNextStoryCode } from '@/lib/code-server' +import { enforceUserRateLimit } from '@/lib/rate-limit' async function getSession() { return getIronSession(await cookies(), sessionOptions) @@ -18,6 +19,9 @@ export async function createTodoAction(_prevState: unknown, formData: FormData) if (!session.userId) return { error: 'Niet ingelogd' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + const limited = enforceUserRateLimit('create-todo', session.userId) + if (limited) return limited + const title = (formData.get('title') as string)?.trim() const description = (formData.get('description') as string)?.trim() || null const raw = (formData.get('productId') as string)?.trim() diff --git a/app/api/profile/avatar/route.ts b/app/api/profile/avatar/route.ts index 3bc4907..ba951c9 100644 --- a/app/api/profile/avatar/route.ts +++ b/app/api/profile/avatar/route.ts @@ -3,6 +3,7 @@ import { getIronSession } from 'iron-session' import sharp from 'sharp' import { prisma } from '@/lib/prisma' import { SessionData, sessionOptions } from '@/lib/session' +import { enforceUserRateLimit } from '@/lib/rate-limit' const MAX_BYTES = 12 * 1024 * 1024 const ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']) @@ -20,6 +21,9 @@ export async function POST(request: Request) { return Response.json({ error: 'Niet beschikbaar in demo-modus' }, { status: 403 }) } + const limited = enforceUserRateLimit('upload-avatar', session.userId) + if (limited) return Response.json({ error: limited.error }, { status: 429 }) + const formData = await request.formData() const file = formData.get('avatar') as File | null if (!file || file.size === 0) { diff --git a/app/api/stories/[id]/log/route.ts b/app/api/stories/[id]/log/route.ts index 42eb60a..a6965e5 100644 --- a/app/api/stories/[id]/log/route.ts +++ b/app/api/stories/[id]/log/route.ts @@ -3,6 +3,7 @@ import { prisma } from '@/lib/prisma' import { productAccessFilter } from '@/lib/product-access' import { Prisma } from '@prisma/client' import { z } from 'zod' +import { enforceUserRateLimit } from '@/lib/rate-limit' const metadataField = z.record(z.string(), z.unknown()).optional() @@ -39,6 +40,9 @@ export async function POST( return Response.json({ error: 'Niet beschikbaar in demo-modus' }, { status: 403 }) } + const limited = enforceUserRateLimit('log-story', auth.userId) + if (limited) return Response.json({ error: limited.error }, { status: 429 }) + const { id: storyId } = await params const story = await prisma.story.findFirst({ diff --git a/components/settings/token-manager.tsx b/components/settings/token-manager.tsx index ba1f705..9916998 100644 --- a/components/settings/token-manager.tsx +++ b/components/settings/token-manager.tsx @@ -38,7 +38,7 @@ export function TokenManager({ tokens, isDemo }: TokenManagerProps) { const [state, formAction] = useActionState( async (_prev: unknown, fd: FormData) => { const result = await createApiTokenAction(_prev, fd) - if (result.success && result.token) { + if ('success' in result && result.success && result.token) { setNewToken(result.token) } return result diff --git a/components/todos/todo-list.tsx b/components/todos/todo-list.tsx index 40f2af3..49962c9 100644 --- a/components/todos/todo-list.tsx +++ b/components/todos/todo-list.tsx @@ -255,11 +255,11 @@ function TodoCard({ const [editState, editFormAction] = useActionState(updateTodoAction, undefined) useEffect(() => { - if (createState?.success) onSuccess() + if (createState && 'success' in createState && createState.success) onSuccess() }, [createState, onSuccess]) useEffect(() => { - if (editState?.success) onSuccess() + if (editState && 'success' in editState && editState.success) onSuccess() }, [editState, onSuccess]) if (mode === 'idle') { diff --git a/docs/plans/v1-readiness.md b/docs/plans/v1-readiness.md index 43c373f..beb1473 100644 --- a/docs/plans/v1-readiness.md +++ b/docs/plans/v1-readiness.md @@ -21,6 +21,8 @@ De kernfunctionaliteit (auth, producten, PBI/story/task-hiërarchie, sprints, so ## What's already done +- **#3 Rate-limiting op alle mutation-endpoints** — `enforceUserRateLimit(scope, userId)` helper in `lib/rate-limit.ts` met 11 nieuwe scopes; toegepast op create-actions (PBI/Story/Task/Todo/Sprint/Product/Token), enqueueClaudeJob(s), answerQuestion, en API-routes (story log POST, avatar upload). Limits zijn ruim genoeg voor normaal gebruik, eng genoeg om abuse-loops te stoppen +- **#2 Sentry error-monitoring** — `@sentry/nextjs` geconfigureerd via PR [#85](https://github.com/madhura68/Scrum4Me/pull/85); SDK is no-op zonder DSN, activatie via Vercel env-vars - **#1 Edit-icoon op Product** (todo `cmoq3ox51`) — pencil-icoon op dashboard-card via PR [#83](https://github.com/madhura68/Scrum4Me/pull/83); product-detail-header behoudt tekst - v0.9.0 ([release](https://github.com/madhura68/Scrum4Me/releases/tag/v0.9.0)): mobile-shell met landscape-lock (PBI-11, 7 stories, 21 tasks) - v0.4.0 t/m v0.8.x: ondermeer sprint-screen filter-popover + edit-iconen, PBI/story/task edit-icons, code-velden verplicht, demo read-only, M11 Claude-vragen-kanaal, M10 QR-pairing @@ -49,14 +51,9 @@ Concreet: - Sample-rate conservatief (10% performance, 100% errors) — Hobby-plan-vriendelijk - Bevestig dat Postgres-LISTEN/NOTIFY-fouten in worker-routes (`/api/realtime/*`) gevangen worden -### 3. Rate-limiting op alle mutation-endpoints +### 3. ~~Rate-limiting op alle mutation-endpoints~~ ✅ klaar -Nu enkel op `loginAction` en `/api/auth/pair/start` (zie [`actions/auth.ts`](../../actions/auth.ts) en [`app/api/auth/pair/start/route.ts`](../../app/api/auth/pair/start/route.ts)). Voor v1 met externe MCP-integratie is breder coverage nodig. - -Concreet: -- Inventariseer alle Server Actions + API-routes die schrijven (Prisma `create/update/delete/upsert`) -- Wikkel `checkRateLimit` per gebruiker (al beschikbaar in [`lib/rate-limit.ts`](../../lib/rate-limit.ts)) om de zware ones — task-create, story-create, claude-question-create, todo-create -- Per-IP fallback voor anonymous-paths +Verschoven naar *What's already done*. Helper `enforceUserRateLimit(scope, userId)` in `lib/rate-limit.ts` toegepast op alle high-value create-paths. ### 4. Accessibility audit op happy-path @@ -125,9 +122,9 @@ Bewust uit scope voor v1 (uit functional spec § Expliciet buiten scope) — of ## Priority order (quick reference) ``` -Now: 1. Edit-icoon op Product (UI-gat) - 2. Sentry/error-monitoring - 3. Rate-limiting op mutation-endpoints +Now: ~~1. Edit-icoon op Product~~ ✅ + ~~2. Sentry/error-monitoring~~ ✅ + ~~3. Rate-limiting op mutation-endpoints~~ ✅ 4. Accessibility-audit (Lighthouse a11y ≥95) Next: 5. Backlog-index.md sync diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts index ba2b052..8193fad 100644 --- a/lib/rate-limit.ts +++ b/lib/rate-limit.ts @@ -11,6 +11,21 @@ const CONFIGS: Record = { login: { windowMs: 60_000, max: 10 }, // 10 attempts per minute register: { windowMs: 3_600_000, max: 5 }, // 5 attempts per hour 'pair-start': { windowMs: 60_000, max: 10 }, // 10 QR-pairings per minute (M10) + + // v1-readiness item 3 — per-user mutation-rate-limits. + // Limits zijn ruim genoeg voor normaal gebruik, eng genoeg om abuse-loops + // (bv. een runaway-script dat duizenden tasks aanmaakt) te stoppen. + 'create-pbi': { windowMs: 60_000, max: 30 }, + 'create-story': { windowMs: 60_000, max: 50 }, + 'create-task': { windowMs: 60_000, max: 100 }, + 'create-todo': { windowMs: 60_000, max: 60 }, + 'create-sprint': { windowMs: 60_000, max: 5 }, + 'create-product': { windowMs: 60_000, max: 5 }, + 'create-token': { windowMs: 3_600_000, max: 10 }, // 10 API-tokens/uur/user + 'enqueue-job': { windowMs: 60_000, max: 30 }, + 'log-story': { windowMs: 60_000, max: 60 }, + 'upload-avatar': { windowMs: 3_600_000, max: 20 }, + 'answer-question': { windowMs: 60_000, max: 30 }, } const DEFAULT_CONFIG: RateLimitConfig = { windowMs: 60_000, max: 10 } @@ -35,3 +50,32 @@ export function checkRateLimit(key: string): boolean { entry.count++ return true } + +/** + * Wrapper voor server-actions: scope op (action, userId), retourneert het + * standaard `{ error, code: 429 }` shape als de gebruiker over de limiet is. + * + * Gebruik in een action: + * + * const limited = enforceUserRateLimit('create-pbi', session.userId) + * if (limited) return limited + */ +export function enforceUserRateLimit( + scope: keyof typeof CONFIGS, + userId: string, +): { error: string; code: 429 } | null { + if (!checkRateLimit(`${scope}:${userId}`)) { + return { + error: 'Te veel acties achter elkaar. Probeer het over een minuut opnieuw.', + code: 429, + } + } + return null +} + +/** + * Voor test-isolatie: leegt de in-memory store. Niet exporteren in productie-paden. + */ +export function _resetRateLimit() { + store.clear() +}