From 8e17f227e4145d99f8987f9e035dcd6c32c6a42c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 9 May 2026 17:42:20 +0200 Subject: [PATCH] fix(admin): rate-limit /api/admin/backups/run to 3/min per admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pg-boss does not deduplicate by payload, so each click on the manual "Backup now" button enqueues a fresh data-backup job. Without a rate-limit, an impatient admin (or compromised admin session via XSS or token theft) can stack dozens of identical fan-out-per-user jobs on the queue. Three runs per minute per admin is plenty for ad-hoc snapshots — the weekly cron in `reminder-worker` already carries the regular cadence. Closes H3 from .planning/phase-6-code-review-findings.md (also addresses M-1 from .planning/phase-6-security-findings.md). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/admin/backups/run/route.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/app/api/admin/backups/run/route.ts b/src/app/api/admin/backups/run/route.ts index ad1f10164..ca0fe2bac 100644 --- a/src/app/api/admin/backups/run/route.ts +++ b/src/app/api/admin/backups/run/route.ts @@ -12,15 +12,29 @@ import { apiHandler, requireAdmin, HttpError } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { checkRateLimit } from "@/lib/rate-limit"; export const dynamic = "force-dynamic"; const DATA_BACKUP_QUEUE = "data-backup"; export const POST = apiHandler(async () => { - await requireAdmin(); + const { user: admin } = await requireAdmin(); annotate({ action: { name: "admin.backups.run" } }); + // pg-boss does not dedupe by payload; without a rate-limit, an + // impatient admin (or compromised admin session) can stack dozens of + // identical jobs on the queue. Three runs/min per admin is plenty + // for ad-hoc snapshots — the weekly cron carries the regular cadence. + const rl = await checkRateLimit( + `admin-backups-run:${admin.id}`, + 3, + 60 * 1000, + ); + if (!rl.allowed) { + throw new HttpError(429, "Too many backup runs"); + } + const boss = getGlobalBoss(); if (!boss) { // 503 instead of 500 — the request is well-formed; the worker just