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:
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user