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

@@ -166,11 +166,12 @@ export async function hardDeleteClient(args: {
const key = codeKey(args.requesterUserId, args.clientId);
const stored = await redis.get(key);
if (!stored) {
throw new ValidationError('Confirmation code expired or not requested');
}
if (!safeEqualStr(stored, args.code.trim())) {
throw new ValidationError('Confirmation code is incorrect');
// Same error for both cases so an attacker can't distinguish "no code
// requested" (probe to know the request endpoint window is open) from
// "wrong code" (probe to brute-force the 4-digit space). The operator
// has the email open and can re-request if expired.
if (!stored || !safeEqualStr(stored, args.code.trim())) {
throw new ValidationError('Invalid or expired confirmation code');
}
// Single-use: delete the code immediately so a failed delete tx
// forces the operator to request a fresh code.
@@ -352,11 +353,10 @@ export async function bulkHardDeleteClients(args: {
const idsHash = hashIds(args.clientIds);
const key = bulkCodeKey(args.requesterUserId, idsHash);
const stored = await redis.get(key);
if (!stored) {
throw new ValidationError('Confirmation code expired or not requested for this exact set');
}
if (!safeEqualStr(stored, args.code.trim())) {
throw new ValidationError('Confirmation code is incorrect');
// Same error for both cases — see single-client variant for rationale.
// Code is tied to the exact set hash so a wrong-set probe fails here too.
if (!stored || !safeEqualStr(stored, args.code.trim())) {
throw new ValidationError('Invalid or expired confirmation code');
}
await redis.del(key);