From e74eff2979b58eeaec91b8047d96b7c91f98d181 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 27 May 2026 07:39:59 +0200 Subject: [PATCH] fix(api): rate-limit POST /api/devices per user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `POST /api/devices` returns a distinct 409 when an `apnsToken` (or the legacy `token`) is already bound to a different account — `apns_token_owned_by_other_user` / `token_owned_by_other_user`. The guard is correct (an attacker who can guess an APNs token shouldn't be able to hijack another account's pushes), but without a rate limit the same 409 signal lets an authenticated user enumerate token ownership across accounts unboundedly. A `checkRateLimit("devices:register:${userId}", 20, 15 * 60 * 1000)` landing before the schema parse caps enumeration at 20 mutations per 15 minutes per user. Real-world clients register a device once on login plus once on each APNs token rotation; 20 / window is well outside any legitimate use. The 429 returns through `apiError()` for envelope parity, and a `devices.register.rate-limited` wide-event annotation lands so operators can see the limit fire. Test mock adds the `@/lib/rate-limit` arm + re-arms the success path inside `beforeEach` because `vi.resetAllMocks()` strips the top-level `mockResolvedValue`. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/devices/__tests__/route.test.ts | 16 ++++++++++++++ src/app/api/devices/route.ts | 23 +++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/app/api/devices/__tests__/route.test.ts b/src/app/api/devices/__tests__/route.test.ts index ac5a464af..cf3136fe2 100644 --- a/src/app/api/devices/__tests__/route.test.ts +++ b/src/app/api/devices/__tests__/route.test.ts @@ -35,6 +35,14 @@ vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ + allowed: true, + remaining: 19, + resetAt: new Date(Date.now() + 15 * 60 * 1000), + }), +})); + vi.mock("next/headers", () => ({ headers: vi.fn(async () => ({ get: () => null })), cookies: vi.fn(async () => ({ @@ -47,6 +55,7 @@ vi.mock("next/headers", () => ({ import { POST } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; +import { checkRateLimit } from "@/lib/rate-limit"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -71,6 +80,13 @@ beforeEach(() => { id: "ch-1", } as never); vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); + // `resetAllMocks` strips the top-level `.mockResolvedValue(...)` + // declaration too, so re-arm the rate-limit success path here. + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 19, + resetAt: new Date(Date.now() + 15 * 60 * 1000), + } as never); }); describe("POST /api/devices", () => { diff --git a/src/app/api/devices/route.ts b/src/app/api/devices/route.ts index 436df7e67..a80a47c73 100644 --- a/src/app/api/devices/route.ts +++ b/src/app/api/devices/route.ts @@ -35,6 +35,7 @@ import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { encrypt } from "@/lib/crypto"; +import { checkRateLimit } from "@/lib/rate-limit"; const deviceSchema = z .object({ @@ -72,6 +73,28 @@ const deviceSchema = z export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + // Per-user rate cap on device registration. The handler returns a + // distinct 409 (`apns_token_owned_by_other_user` / + // `token_owned_by_other_user`) when an `apnsToken` is already + // bound to a different account — useful for the legitimate + // cross-user-hijack guard, but it also means an authenticated user + // could enumerate token ownership across accounts unboundedly. 20 + // device-mutations per 15 minutes is well outside any realistic + // client behaviour (the iOS app registers once on login + on token + // rotation; not 20× / window) while capping enumeration cost. + const rl = await checkRateLimit( + `devices:register:${user.id}`, + 20, + 15 * 60 * 1000, + ); + if (!rl.allowed) { + annotate({ + action: { name: "devices.register.rate-limited" }, + meta: { userId: user.id, resetAt: rl.resetAt }, + }); + return apiError("Too many device registrations, please slow down", 429); + } + const { data: body, error } = await safeJson(request); if (error) return error;