mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Addresses the L1 finding from the final security review. The six admin-class write endpoints under /api/account had no rate limits, so a compromised admin session (or a script run in a loop) could spam invite creation / role flips / member removes fast enough to flood the DB or audit logs. Adds a new `RATE_LIMITS.adminAction` bucket — 30/min/user — and wires it into: - POST /api/account/invitations → admin:inviteCreate - DELETE /api/account/invitations/[id] → admin:inviteRevoke - PATCH /api/account/members/[userId] → admin:memberRole - DELETE /api/account/members/[userId] → admin:memberRemove - POST /api/account/transfer-ownership → admin:transferOwnership - PATCH /api/account → admin:rename Each endpoint keys its own bucket so spamming one doesn't starve another. 30/min is comfortably above any realistic human pace (the Members tab is a clicks-only UI; a legit admin would struggle to hit ten of these per minute by hand) while still bounding accidental abuse. The bucket joins the existing send / broadcast / react / invitationPeek / invitationRedeem buckets in `RATE_LIMITS` — same in-memory fixed-window limiter, same `rateLimitResponse` helper for the 429 shape. Severity in the final review: LOW. The admin-trust model means this isn't blocking — but the cost of adding it is six near-identical 4-line additions and zero net risk, so worth landing for hardening. Verification - `npm run lint` → 0 errors (same 19 pre-existing warnings) - `npm run typecheck` → clean - `npm test` → 366/366 pass Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
74 lines
2.3 KiB
TypeScript
74 lines
2.3 KiB
TypeScript
// ============================================================
|
|
// DELETE /api/account/invitations/[id]
|
|
//
|
|
// Admin+. Revokes a pending invitation by id. RLS on
|
|
// `account_invitations` already restricts the DELETE to admins
|
|
// of the inviting account; we lean on it and skip the explicit
|
|
// ownership check.
|
|
//
|
|
// We intentionally delete the row outright rather than soft-
|
|
// deleting (a "revoked_at" flag). Once revoked, an invite is
|
|
// dead forever — there's no UX where a former invite should be
|
|
// listed; the plaintext token is gone too. Hard delete keeps
|
|
// the table small.
|
|
// ============================================================
|
|
|
|
import { NextResponse } from "next/server";
|
|
|
|
import { requireRole, toErrorResponse } from "@/lib/auth/account";
|
|
import {
|
|
checkRateLimit,
|
|
rateLimitResponse,
|
|
RATE_LIMITS,
|
|
} from "@/lib/rate-limit";
|
|
|
|
export async function DELETE(
|
|
_request: Request,
|
|
{ params }: { params: Promise<{ id: string }> },
|
|
) {
|
|
try {
|
|
const ctx = await requireRole("admin");
|
|
|
|
const limit = checkRateLimit(
|
|
`admin:inviteRevoke:${ctx.userId}`,
|
|
RATE_LIMITS.adminAction,
|
|
);
|
|
if (!limit.success) return rateLimitResponse(limit);
|
|
|
|
const { id } = await params;
|
|
|
|
// No `eq('account_id', ctx.accountId)` — the RLS policy
|
|
// (`is_account_member(account_id, 'admin')`) already scopes
|
|
// the DELETE to invites in the caller's account. Adding the
|
|
// filter would be redundant; omitting it surfaces a
|
|
// cross-account attempt as a silent 0-row delete (which is
|
|
// exactly what we want for a revocation endpoint).
|
|
const { error, count } = await ctx.supabase
|
|
.from("account_invitations")
|
|
.delete({ count: "exact" })
|
|
.eq("id", id);
|
|
|
|
if (error) {
|
|
console.error("[DELETE /api/account/invitations/[id]] error:", error);
|
|
return NextResponse.json(
|
|
{ error: "Failed to revoke invitation" },
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
|
|
if (count === 0) {
|
|
// Either the id doesn't exist or RLS hid it (different
|
|
// account). 404 either way — surfacing "exists but not
|
|
// yours" would leak existence.
|
|
return NextResponse.json(
|
|
{ error: "Invitation not found" },
|
|
{ status: 404 },
|
|
);
|
|
}
|
|
|
|
return NextResponse.json({ ok: true });
|
|
} catch (err) {
|
|
return toErrorResponse(err);
|
|
}
|
|
}
|