mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user