mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
feat(api): per-user rate limits on admin account endpoints
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>
This commit is contained in:
parent
c247fd0a9b
commit
01715c7d6d
@ -16,6 +16,11 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { requireRole, toErrorResponse } from "@/lib/auth/account";
|
import { requireRole, toErrorResponse } from "@/lib/auth/account";
|
||||||
|
import {
|
||||||
|
checkRateLimit,
|
||||||
|
rateLimitResponse,
|
||||||
|
RATE_LIMITS,
|
||||||
|
} from "@/lib/rate-limit";
|
||||||
|
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
_request: Request,
|
_request: Request,
|
||||||
@ -23,6 +28,13 @@ export async function DELETE(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const ctx = await requireRole("admin");
|
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;
|
const { id } = await params;
|
||||||
|
|
||||||
// No `eq('account_id', ctx.accountId)` — the RLS policy
|
// No `eq('account_id', ctx.accountId)` — the RLS policy
|
||||||
|
|||||||
@ -27,6 +27,11 @@ import {
|
|||||||
inviteUrl,
|
inviteUrl,
|
||||||
} from "@/lib/auth/invitations";
|
} from "@/lib/auth/invitations";
|
||||||
import { isAccountRole } from "@/lib/auth/roles";
|
import { isAccountRole } from "@/lib/auth/roles";
|
||||||
|
import {
|
||||||
|
checkRateLimit,
|
||||||
|
rateLimitResponse,
|
||||||
|
RATE_LIMITS,
|
||||||
|
} from "@/lib/rate-limit";
|
||||||
|
|
||||||
// Resolve the base URL we publish invite links under.
|
// Resolve the base URL we publish invite links under.
|
||||||
//
|
//
|
||||||
@ -115,6 +120,16 @@ export async function POST(request: Request) {
|
|||||||
try {
|
try {
|
||||||
const ctx = await requireRole("admin");
|
const ctx = await requireRole("admin");
|
||||||
|
|
||||||
|
// 30/min per user. The Members tab is a clicks-only UI so any
|
||||||
|
// legitimate admin is far below this; the cap exists to keep
|
||||||
|
// a script run in a loop or a compromised admin session from
|
||||||
|
// flooding `account_invitations` with rows.
|
||||||
|
const limit = checkRateLimit(
|
||||||
|
`admin:inviteCreate:${ctx.userId}`,
|
||||||
|
RATE_LIMITS.adminAction,
|
||||||
|
);
|
||||||
|
if (!limit.success) return rateLimitResponse(limit);
|
||||||
|
|
||||||
const body = (await request.json().catch(() => null)) as
|
const body = (await request.json().catch(() => null)) as
|
||||||
| { role?: unknown; expiresInDays?: unknown; label?: unknown }
|
| { role?: unknown; expiresInDays?: unknown; label?: unknown }
|
||||||
| null;
|
| null;
|
||||||
|
|||||||
@ -19,6 +19,11 @@ import type { PostgrestError } from "@supabase/supabase-js";
|
|||||||
|
|
||||||
import { requireRole, toErrorResponse } from "@/lib/auth/account";
|
import { requireRole, toErrorResponse } from "@/lib/auth/account";
|
||||||
import { isAccountRole } from "@/lib/auth/roles";
|
import { isAccountRole } from "@/lib/auth/roles";
|
||||||
|
import {
|
||||||
|
checkRateLimit,
|
||||||
|
rateLimitResponse,
|
||||||
|
RATE_LIMITS,
|
||||||
|
} from "@/lib/rate-limit";
|
||||||
|
|
||||||
// Map known SQLSTATEs from the RPCs (see migration 018) onto HTTP
|
// Map known SQLSTATEs from the RPCs (see migration 018) onto HTTP
|
||||||
// statuses. The `error.code` field is the SQLSTATE; the `message`
|
// statuses. The `error.code` field is the SQLSTATE; the `message`
|
||||||
@ -43,6 +48,13 @@ export async function PATCH(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const ctx = await requireRole("admin");
|
const ctx = await requireRole("admin");
|
||||||
|
|
||||||
|
const limit = checkRateLimit(
|
||||||
|
`admin:memberRole:${ctx.userId}`,
|
||||||
|
RATE_LIMITS.adminAction,
|
||||||
|
);
|
||||||
|
if (!limit.success) return rateLimitResponse(limit);
|
||||||
|
|
||||||
const { userId } = await params;
|
const { userId } = await params;
|
||||||
|
|
||||||
const body = (await request.json().catch(() => null)) as
|
const body = (await request.json().catch(() => null)) as
|
||||||
@ -88,6 +100,13 @@ export async function DELETE(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const ctx = await requireRole("admin");
|
const ctx = await requireRole("admin");
|
||||||
|
|
||||||
|
const limit = checkRateLimit(
|
||||||
|
`admin:memberRemove:${ctx.userId}`,
|
||||||
|
RATE_LIMITS.adminAction,
|
||||||
|
);
|
||||||
|
if (!limit.success) return rateLimitResponse(limit);
|
||||||
|
|
||||||
const { userId } = await params;
|
const { userId } = await params;
|
||||||
|
|
||||||
const { data, error } = await ctx.supabase.rpc("remove_account_member", {
|
const { data, error } = await ctx.supabase.rpc("remove_account_member", {
|
||||||
|
|||||||
@ -18,6 +18,11 @@ import {
|
|||||||
getCurrentAccount,
|
getCurrentAccount,
|
||||||
toErrorResponse,
|
toErrorResponse,
|
||||||
} from "@/lib/auth/account";
|
} from "@/lib/auth/account";
|
||||||
|
import {
|
||||||
|
checkRateLimit,
|
||||||
|
rateLimitResponse,
|
||||||
|
RATE_LIMITS,
|
||||||
|
} from "@/lib/rate-limit";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
@ -37,6 +42,16 @@ export async function PATCH(request: Request) {
|
|||||||
try {
|
try {
|
||||||
const ctx = await requireRole("admin");
|
const ctx = await requireRole("admin");
|
||||||
|
|
||||||
|
// Per-user limit on admin-class mutations. Bounds accidental
|
||||||
|
// abuse (script run in a loop) and a compromised admin session
|
||||||
|
// spamming renames. Each admin endpoint keys its own bucket so
|
||||||
|
// one route doesn't starve another.
|
||||||
|
const limit = checkRateLimit(
|
||||||
|
`admin:rename:${ctx.userId}`,
|
||||||
|
RATE_LIMITS.adminAction,
|
||||||
|
);
|
||||||
|
if (!limit.success) return rateLimitResponse(limit);
|
||||||
|
|
||||||
const body = (await request.json().catch(() => null)) as
|
const body = (await request.json().catch(() => null)) as
|
||||||
| { name?: unknown }
|
| { name?: unknown }
|
||||||
| null;
|
| null;
|
||||||
|
|||||||
@ -22,6 +22,11 @@ import { NextResponse } from "next/server";
|
|||||||
import type { PostgrestError } from "@supabase/supabase-js";
|
import type { PostgrestError } from "@supabase/supabase-js";
|
||||||
|
|
||||||
import { requireRole, toErrorResponse } from "@/lib/auth/account";
|
import { requireRole, toErrorResponse } from "@/lib/auth/account";
|
||||||
|
import {
|
||||||
|
checkRateLimit,
|
||||||
|
rateLimitResponse,
|
||||||
|
RATE_LIMITS,
|
||||||
|
} from "@/lib/rate-limit";
|
||||||
|
|
||||||
function rpcErrorToResponse(err: PostgrestError): NextResponse {
|
function rpcErrorToResponse(err: PostgrestError): NextResponse {
|
||||||
if (err.code === "42501") {
|
if (err.code === "42501") {
|
||||||
@ -54,6 +59,16 @@ export async function POST(request: Request) {
|
|||||||
// on the obvious "admin trying to transfer" case.
|
// on the obvious "admin trying to transfer" case.
|
||||||
const ctx = await requireRole("owner");
|
const ctx = await requireRole("owner");
|
||||||
|
|
||||||
|
// Rate-limit owner-only transfers. Legitimate use is one click
|
||||||
|
// every few months at most; a script run in a loop would
|
||||||
|
// produce a noisy audit trail. 30/min is well above any human
|
||||||
|
// pace and bounds the noise.
|
||||||
|
const limit = checkRateLimit(
|
||||||
|
`admin:transferOwnership:${ctx.userId}`,
|
||||||
|
RATE_LIMITS.adminAction,
|
||||||
|
);
|
||||||
|
if (!limit.success) return rateLimitResponse(limit);
|
||||||
|
|
||||||
const body = (await request.json().catch(() => null)) as
|
const body = (await request.json().catch(() => null)) as
|
||||||
| { newOwnerUserId?: unknown }
|
| { newOwnerUserId?: unknown }
|
||||||
| null;
|
| null;
|
||||||
|
|||||||
@ -134,6 +134,13 @@ export const RATE_LIMITS = {
|
|||||||
* successful redemption mutates two profiles and an invite row, so
|
* successful redemption mutates two profiles and an invite row, so
|
||||||
* the abuse surface is "spam join attempts." */
|
* the abuse surface is "spam join attempts." */
|
||||||
invitationRedeem: { limit: 10, windowMs: 60_000 },
|
invitationRedeem: { limit: 10, windowMs: 60_000 },
|
||||||
|
/** Admin-only account / member-management actions: create/revoke
|
||||||
|
* invitation, rename account, change member role, remove member,
|
||||||
|
* transfer ownership. 30/min per user is comfortably above any
|
||||||
|
* realistic legitimate use (the Members tab is a clicks-only UI)
|
||||||
|
* while still bounding accidental abuse from a script run in a
|
||||||
|
* loop or a compromised admin session spamming role flips. */
|
||||||
|
adminAction: { limit: 30, windowMs: 60_000 },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** Test-only helper. Clears the in-memory state so unit tests don't
|
/** Test-only helper. Clears the in-memory state so unit tests don't
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user