mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Merge pull request #190 from ArnasDon/feat/rate-limit-admin-account-endpoints
feat(api): per-user rate limits on admin account endpoints
This commit is contained in:
commit
d23a5a5017
@ -16,6 +16,11 @@
|
||||
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,
|
||||
@ -23,6 +28,13 @@ export async function DELETE(
|
||||
) {
|
||||
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
|
||||
|
||||
@ -27,6 +27,11 @@ import {
|
||||
inviteUrl,
|
||||
} from "@/lib/auth/invitations";
|
||||
import { isAccountRole } from "@/lib/auth/roles";
|
||||
import {
|
||||
checkRateLimit,
|
||||
rateLimitResponse,
|
||||
RATE_LIMITS,
|
||||
} from "@/lib/rate-limit";
|
||||
|
||||
// Resolve the base URL we publish invite links under.
|
||||
//
|
||||
@ -163,6 +168,16 @@ export async function POST(request: Request) {
|
||||
try {
|
||||
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
|
||||
| { role?: unknown; expiresInDays?: unknown; label?: unknown }
|
||||
| null;
|
||||
|
||||
@ -19,6 +19,11 @@ import type { PostgrestError } from "@supabase/supabase-js";
|
||||
|
||||
import { requireRole, toErrorResponse } from "@/lib/auth/account";
|
||||
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
|
||||
// statuses. The `error.code` field is the SQLSTATE; the `message`
|
||||
@ -43,6 +48,13 @@ export async function PATCH(
|
||||
) {
|
||||
try {
|
||||
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 body = (await request.json().catch(() => null)) as
|
||||
@ -88,6 +100,13 @@ export async function DELETE(
|
||||
) {
|
||||
try {
|
||||
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 { data, error } = await ctx.supabase.rpc("remove_account_member", {
|
||||
|
||||
@ -18,6 +18,11 @@ import {
|
||||
getCurrentAccount,
|
||||
toErrorResponse,
|
||||
} from "@/lib/auth/account";
|
||||
import {
|
||||
checkRateLimit,
|
||||
rateLimitResponse,
|
||||
RATE_LIMITS,
|
||||
} from "@/lib/rate-limit";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
@ -37,6 +42,16 @@ export async function PATCH(request: Request) {
|
||||
try {
|
||||
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
|
||||
| { name?: unknown }
|
||||
| null;
|
||||
|
||||
@ -22,6 +22,11 @@ import { NextResponse } from "next/server";
|
||||
import type { PostgrestError } from "@supabase/supabase-js";
|
||||
|
||||
import { requireRole, toErrorResponse } from "@/lib/auth/account";
|
||||
import {
|
||||
checkRateLimit,
|
||||
rateLimitResponse,
|
||||
RATE_LIMITS,
|
||||
} from "@/lib/rate-limit";
|
||||
|
||||
function rpcErrorToResponse(err: PostgrestError): NextResponse {
|
||||
if (err.code === "42501") {
|
||||
@ -54,6 +59,16 @@ export async function POST(request: Request) {
|
||||
// on the obvious "admin trying to transfer" case.
|
||||
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
|
||||
| { newOwnerUserId?: unknown }
|
||||
| null;
|
||||
|
||||
@ -134,6 +134,13 @@ export const RATE_LIMITS = {
|
||||
* successful redemption mutates two profiles and an invite row, so
|
||||
* the abuse surface is "spam join attempts." */
|
||||
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;
|
||||
|
||||
/** Test-only helper. Clears the in-memory state so unit tests don't
|
||||
|
||||
Loading…
Reference in New Issue
Block a user