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:
Matt Ciaccio
2026-05-06 22:06:40 +02:00
parent c5b41ca4b5
commit 588f8bc43c
5 changed files with 97 additions and 65 deletions

View File

@@ -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);
}
}),
),
);

View File

@@ -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);
}
}),
),
);

View File

@@ -14,6 +14,7 @@ import {
} from '@/lib/services/documents.service';
import { logger } from '@/lib/logger';
import { createAuditLog } from '@/lib/audit';
import { checkRateLimit, rateLimiters } from '@/lib/rate-limit';
// BR-024: Dedup via signatureHash unique index on documentEvents
// Always return 200 from webhook (webhook best practice)
@@ -66,22 +67,36 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
}
}
if (!matched) {
logger.warn({ providedLen: providedSecret.length }, 'Invalid Documenso webhook secret');
void createAuditLog({
userId: null,
portId: null,
action: 'webhook_failed',
entityType: 'webhook_inbound',
entityId: 'documenso',
metadata: {
reason: 'invalid_secret',
providedLen: providedSecret.length,
},
ipAddress: req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? '',
userAgent: req.headers.get('user-agent') ?? '',
severity: 'warning',
source: 'webhook',
});
const callerIp =
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
req.headers.get('x-real-ip') ??
'unknown';
// Rate-limit per IP. Real Documenso traffic won't fail the secret
// check, so any traffic here is enumeration / brute-force; we cap
// it sharply to keep audit-log volume bounded too.
const rl = await checkRateLimit(callerIp, rateLimiters.webhookBadSecret);
logger.warn(
{ providedLen: providedSecret.length, ip: callerIp, allowed: rl.allowed },
'Invalid Documenso webhook secret',
);
if (rl.allowed) {
void createAuditLog({
userId: null,
portId: null,
action: 'webhook_failed',
entityType: 'webhook_inbound',
entityId: 'documenso',
metadata: {
reason: 'invalid_secret',
providedLen: providedSecret.length,
},
ipAddress: callerIp,
userAgent: req.headers.get('user-agent') ?? '',
severity: 'warning',
source: 'webhook',
});
}
// Always return 200 (webhook best-practice — don't leak signal).
return NextResponse.json({ ok: false, error: 'Invalid secret' }, { status: 200 });
}