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:
Arnas Donauskas 2026-06-01 15:12:49 +03:00 committed by GitHub
commit d23a5a5017
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 83 additions and 0 deletions

View File

@ -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

View File

@ -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.
// //
@ -163,6 +168,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;

View File

@ -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", {

View File

@ -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;

View File

@ -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;

View File

@ -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