fix(api): rate-limit POST /api/devices per user

`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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-27 07:39:59 +02:00
1 parent a680785023
commit e74eff2979
2 files changed
+39

No files matched your search

@@ -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", () => {
+23
View File
@@ -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;