fix(audit-verification): regressions found in post-Tier-6 review

Two parallel reviews of the Tier 0–6 work surfaced one CRITICAL
regression and a handful of remaining cross-tenant gaps that the
original audit didn't enumerate. All fixed here:

CRITICAL
* document-reminders.processReminderQueue — the new bulk-fetch
  leftJoin to documentTemplates was scoped on `templateType` alone.
  Templates of the same type exist in every port; the cartesian
  explosion would have fired one Documenso reminder PER matching
  template-row per cron tick (a 5-port deploy = 5 reminders to the
  same signer per cycle). Added eq(documentTemplates.portId, portId)
  to the join.
* All five remaining Documenso webhook handlers (RecipientSigned /
  Completed / Opened / Rejected / Cancelled) accept and require an
  optional portId now, with a shared resolveWebhookDocument() helper
  that refuses to mutate when the lookup is ambiguous across tenants
  without a resolved port. Tier 5's port-scoping was applied only to
  Expired; the route now forwards the matched portId to every
  handler. Tightens the WHERE clauses on subsequent UPDATEs to (id,
  portId) for defense-in-depth.

HIGH
* verifyDocumensoSecret rejects when `expected` is empty —
  timingSafeEqual(0-bytes, 0-bytes) was returning true, so a dev env
  with a blank DOCUMENSO_WEBHOOK_SECRET would accept a request whose
  X-Documenso-Secret header was also missing/empty.
  listDocumensoWebhookSecrets skips the env entry when blank.
* /api/public/health — the website-intake-secret comparison was a
  string `===` (not constant-time). Switched to timingSafeEqual via
  Buffer.from().

MEDIUM
* server.ts SIGTERM ordering — Socket.io closes BEFORE the HTTP
  drain so long-poll websockets stop holding the server open past
  the compose stop_grace_period.
* /api/v1/me PATCH preferences merge — allow-list filter on the
  merged JSONB so legacy rows from the old .passthrough() era stop
  silently re-shipping their bloat to disk.

Migration fixes (deploy-blocking)
* 0041 referenced `port_role_overrides.permissions` (column is
  `permission_overrides`) — overrides are partial JSONB and don't
  need backfilling at all (deepMerge resolves edit from the base
  role). Removed the override UPDATEs entirely.
* 0042 switched all FK + CHECK adds to NOT VALID + VALIDATE so the
  brief table-lock phase is decoupled from the row-scan validation,
  giving a cleaner abort-and-restart story if a constraint catches
  dirty production data. Added a pre-cleanup UPDATE for
  invoices.billing_entity_id = '' rows (backfills from clientName,
  falls back to the row id) so the new non-empty CHECK passes on a
  dirty table.

Test status: 1175/1175 vitest, tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-05 21:19:39 +02:00
parent 83239104e0
commit 63c4073e64
10 changed files with 218 additions and 150 deletions

View File

@@ -104,6 +104,13 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
return NextResponse.json({ ok: true }, { status: 200 });
}
// Every handler accepts an optional `portId` and refuses to mutate when
// the lookup is ambiguous across multiple ports without one. Forward
// the secret-resolved portId everywhere — not just the expired path —
// so signed/completed/opened/rejected/cancelled events can't flip a
// foreign-tenant document via documensoId reuse.
const portScope = matchedPortId ? { portId: matchedPortId } : {};
try {
switch (event) {
case 'DOCUMENT_SIGNED':
@@ -118,6 +125,7 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
documentId: documensoId,
recipientEmail: r.email,
signatureHash: `${signatureHash}:signed:${r.email}`,
...portScope,
});
}
break;
@@ -138,13 +146,14 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
documentId: documensoId,
recipientEmail: r.email,
signatureHash: `${signatureHash}:opened:${r.email}`,
...portScope,
});
}
break;
}
case 'DOCUMENT_COMPLETED':
await handleDocumentCompleted({ documentId: documensoId });
await handleDocumentCompleted({ documentId: documensoId, ...portScope });
break;
case 'DOCUMENT_REJECTED': {
@@ -153,21 +162,17 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
documentId: documensoId,
recipientEmail: rejecting?.email,
signatureHash,
...portScope,
});
break;
}
case 'DOCUMENT_CANCELLED':
await handleDocumentCancelled({ documentId: documensoId, signatureHash });
await handleDocumentCancelled({ documentId: documensoId, signatureHash, ...portScope });
break;
case 'DOCUMENT_EXPIRED':
// Forward the matched portId so cross-port documenso-id reuse
// can't flip the wrong port's document.
await handleDocumentExpired({
documentId: documensoId,
...(matchedPortId ? { portId: matchedPortId } : {}),
});
await handleDocumentExpired({ documentId: documensoId, ...portScope });
break;
default: