fix(audit): security HIGHs — rate-limit hard-delete codes, collapse error msgs, doc bad-secret per-IP
H1: hard-delete-request and bulk-hard-delete-request endpoints had no rate limit; an admin's compromised account could email-bomb the operator's inbox or use the endpoints as a client-id oracle. Added a new `hardDeleteCode` limiter (5 per hour per user). H3: hard-delete error messages distinguished "no code requested" from "wrong code", letting an attacker brute-force the 4-digit space with ~5k attempts (vs the full 10k). Both single + bulk paths now return the same 'Invalid or expired confirmation code' message. H5: invalid Documenso webhook secret submissions are now rate-limited per-IP (10 per 15min) and only audit-logged inside the cap, so a slow enumeration can't fill the audit log silently. Real Documenso traffic won't fail the secret check, so any traffic beyond the cap is brute-force. 1175/1175 vitest passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { withAuth, withPermission, withRateLimit } from '@/lib/api/helpers';
|
||||
import { requestHardDeleteCode } from '@/lib/services/client-hard-delete.service';
|
||||
import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||
|
||||
@@ -13,26 +13,30 @@ import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||
* checked by the partner /hard-delete route — see route comment there.
|
||||
*/
|
||||
export const POST = withAuth(
|
||||
withPermission('admin', 'permanently_delete_clients', async (_req, ctx, params) => {
|
||||
try {
|
||||
const id = params.id;
|
||||
if (!id) throw new NotFoundError('client');
|
||||
withPermission(
|
||||
'admin',
|
||||
'permanently_delete_clients',
|
||||
withRateLimit('hardDeleteCode', async (_req, ctx, params) => {
|
||||
try {
|
||||
const id = params.id;
|
||||
if (!id) throw new NotFoundError('client');
|
||||
|
||||
const result = await requestHardDeleteCode({
|
||||
clientId: id,
|
||||
portId: ctx.portId,
|
||||
requesterUserId: ctx.userId,
|
||||
meta: {
|
||||
userId: ctx.userId,
|
||||
const result = await requestHardDeleteCode({
|
||||
clientId: id,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
});
|
||||
requesterUserId: ctx.userId,
|
||||
meta: {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ data: result });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
return NextResponse.json({ data: result });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { withAuth, withPermission, withRateLimit } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { requestBulkHardDeleteCode } from '@/lib/services/client-hard-delete.service';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
@@ -11,23 +11,27 @@ const bodySchema = z.object({
|
||||
});
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('admin', 'permanently_delete_clients', async (req, ctx) => {
|
||||
try {
|
||||
const { ids } = await parseBody(req, bodySchema);
|
||||
const result = await requestBulkHardDeleteCode({
|
||||
clientIds: ids,
|
||||
portId: ctx.portId,
|
||||
requesterUserId: ctx.userId,
|
||||
meta: {
|
||||
userId: ctx.userId,
|
||||
withPermission(
|
||||
'admin',
|
||||
'permanently_delete_clients',
|
||||
withRateLimit('hardDeleteCode', async (req, ctx) => {
|
||||
try {
|
||||
const { ids } = await parseBody(req, bodySchema);
|
||||
const result = await requestBulkHardDeleteCode({
|
||||
clientIds: ids,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
});
|
||||
return NextResponse.json({ data: result });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
requesterUserId: ctx.userId,
|
||||
meta: {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
});
|
||||
return NextResponse.json({ data: result });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user