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:
@@ -3,8 +3,16 @@ import { timingSafeEqual } from 'crypto';
|
||||
// Documenso (v1.13 + 2.x) authenticates outbound webhooks by sending the
|
||||
// configured secret in plaintext via the `X-Documenso-Secret` header.
|
||||
// There is no HMAC. Compare the provided value timing-safely to the env secret.
|
||||
//
|
||||
// An empty `expected` MUST always reject — without this guard,
|
||||
// timingSafeEqual(0-bytes, 0-bytes) returns true, so a dev environment
|
||||
// with a blank DOCUMENSO_WEBHOOK_SECRET would accept any request whose
|
||||
// `X-Documenso-Secret` was also empty/missing. Same for blank per-port
|
||||
// secret rows in `system_settings` (the per-port writer should never
|
||||
// store an empty string but defense-in-depth here is cheap).
|
||||
export function verifyDocumensoSecret(provided: string, expected: string): boolean {
|
||||
if (!provided || provided.length !== expected.length) return false;
|
||||
if (!provided || !expected) return false;
|
||||
if (provided.length !== expected.length) return false;
|
||||
try {
|
||||
return timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
|
||||
} catch {
|
||||
|
||||
@@ -206,7 +206,20 @@ export async function processReminderQueue(portId: string): Promise<void> {
|
||||
fileId: documents.fileId,
|
||||
})
|
||||
.from(documents)
|
||||
.leftJoin(documentTemplates, eq(documentTemplates.templateType, documents.documentType))
|
||||
// CRITICAL: scope the join to the same port — `documentTemplates.templateType`
|
||||
// is not unique across ports, so a leftJoin without `portId` produces a
|
||||
// cartesian explosion (one output row per template-type match across
|
||||
// every port). The downstream loop fires `documensoRemind` per row,
|
||||
// which means the same signer in port A would receive N reminders on
|
||||
// a single cron tick (once per port that defined a template of the
|
||||
// same type). Audit follow-up after Tier 3 ship.
|
||||
.leftJoin(
|
||||
documentTemplates,
|
||||
and(
|
||||
eq(documentTemplates.templateType, documents.documentType),
|
||||
eq(documentTemplates.portId, portId),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(documents.portId, portId),
|
||||
|
||||
@@ -780,18 +780,59 @@ export async function listDocumentEvents(documentId: string, portId: string) {
|
||||
|
||||
// ─── Webhook Handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Shared port-scoped lookup for inbound Documenso webhooks. Two ports
|
||||
* sharing a Documenso instance — or migrating between instances with
|
||||
* documentId reuse — would otherwise let `findFirst` return whichever
|
||||
* row sorts first across tenants. When the route resolves a portId from
|
||||
* the matched per-port webhook secret it threads it here; otherwise we
|
||||
* fall back to a port-agnostic `findMany` and refuse to mutate when the
|
||||
* lookup is ambiguous (mirrors the guard in handleDocumentExpired).
|
||||
*
|
||||
* Returns null when no document matches OR when the lookup is ambiguous
|
||||
* across multiple ports without a resolved portId. Callers must treat
|
||||
* null as "drop the event" (the cron sweep / next webhook will catch up
|
||||
* once the data is consistent).
|
||||
*/
|
||||
async function resolveWebhookDocument(
|
||||
documensoId: string,
|
||||
portId: string | undefined,
|
||||
): Promise<typeof documents.$inferSelect | null> {
|
||||
if (portId) {
|
||||
const doc = await db.query.documents.findFirst({
|
||||
where: and(eq(documents.documensoId, documensoId), eq(documents.portId, portId)),
|
||||
});
|
||||
if (!doc) {
|
||||
logger.warn({ documensoId, portId }, 'Document not found for webhook (port-scoped)');
|
||||
return null;
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
const matches = await db.query.documents.findMany({
|
||||
where: eq(documents.documensoId, documensoId),
|
||||
});
|
||||
if (matches.length === 0) {
|
||||
logger.warn({ documensoId }, 'Document not found for webhook');
|
||||
return null;
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
logger.error(
|
||||
{ documensoId, matchCount: matches.length, ports: matches.map((m) => m.portId) },
|
||||
'Documenso webhook ambiguous across multiple ports — refusing to mutate',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return matches[0]!;
|
||||
}
|
||||
|
||||
export async function handleRecipientSigned(eventData: {
|
||||
documentId: string;
|
||||
recipientEmail: string;
|
||||
signatureHash?: string;
|
||||
portId?: string;
|
||||
}) {
|
||||
const doc = await db.query.documents.findFirst({
|
||||
where: eq(documents.documensoId, eventData.documentId),
|
||||
});
|
||||
if (!doc) {
|
||||
logger.warn({ documensoId: eventData.documentId }, 'Document not found for webhook');
|
||||
return;
|
||||
}
|
||||
const doc = await resolveWebhookDocument(eventData.documentId, eventData.portId);
|
||||
if (!doc) return;
|
||||
|
||||
// Update signer status
|
||||
const [signer] = await db
|
||||
@@ -826,7 +867,7 @@ export async function handleRecipientSigned(eventData: {
|
||||
await db
|
||||
.update(documents)
|
||||
.set({ status: 'partially_signed', updatedAt: new Date() })
|
||||
.where(eq(documents.id, doc.id));
|
||||
.where(and(eq(documents.id, doc.id), eq(documents.portId, doc.portId)));
|
||||
}
|
||||
|
||||
await db.insert(documentEvents).values({
|
||||
@@ -843,14 +884,9 @@ export async function handleRecipientSigned(eventData: {
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleDocumentCompleted(eventData: { documentId: string }) {
|
||||
const doc = await db.query.documents.findFirst({
|
||||
where: eq(documents.documensoId, eventData.documentId),
|
||||
});
|
||||
if (!doc) {
|
||||
logger.warn({ documensoId: eventData.documentId }, 'Document not found for webhook');
|
||||
return;
|
||||
}
|
||||
export async function handleDocumentCompleted(eventData: { documentId: string; portId?: string }) {
|
||||
const doc = await resolveWebhookDocument(eventData.documentId, eventData.portId);
|
||||
if (!doc) return;
|
||||
|
||||
// BR-022: Download signed PDF and store in MinIO
|
||||
const port = await db.query.ports.findFirst({ where: eq(ports.id, doc.portId) });
|
||||
@@ -980,38 +1016,8 @@ export async function handleDocumentCompleted(eventData: { documentId: string })
|
||||
}
|
||||
|
||||
export async function handleDocumentExpired(eventData: { documentId: string; portId?: string }) {
|
||||
// Port-scoped lookup when the caller resolved a portId from the
|
||||
// webhook signature. Two ports holding the same Documenso instance
|
||||
// (or migrating between instances with id reuse) would otherwise
|
||||
// share a documensoId across tenants, and findFirst would return
|
||||
// whichever row sorted first — flipping a foreign-port document.
|
||||
const matches = await db.query.documents.findMany({
|
||||
where: eventData.portId
|
||||
? and(eq(documents.documensoId, eventData.documentId), eq(documents.portId, eventData.portId))
|
||||
: eq(documents.documensoId, eventData.documentId),
|
||||
});
|
||||
|
||||
if (matches.length === 0) {
|
||||
logger.warn({ documensoId: eventData.documentId }, 'Document not found for webhook');
|
||||
return;
|
||||
}
|
||||
|
||||
if (matches.length > 1 && !eventData.portId) {
|
||||
// Cross-tenant ambiguity. Refuse to mutate without a resolved port —
|
||||
// safer to drop the event (the cron expiry sweep will catch up) than
|
||||
// flip the wrong document.
|
||||
logger.error(
|
||||
{
|
||||
documensoId: eventData.documentId,
|
||||
matchCount: matches.length,
|
||||
ports: matches.map((m) => m.portId),
|
||||
},
|
||||
'Document expired webhook ambiguous across multiple ports — refusing to mutate',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = matches[0]!;
|
||||
const doc = await resolveWebhookDocument(eventData.documentId, eventData.portId);
|
||||
if (!doc) return;
|
||||
|
||||
await db
|
||||
.update(documents)
|
||||
@@ -1038,14 +1044,10 @@ export async function handleDocumentOpened(eventData: {
|
||||
documentId: string;
|
||||
recipientEmail: string;
|
||||
signatureHash?: string;
|
||||
portId?: string;
|
||||
}) {
|
||||
const doc = await db.query.documents.findFirst({
|
||||
where: eq(documents.documensoId, eventData.documentId),
|
||||
});
|
||||
if (!doc) {
|
||||
logger.warn({ documensoId: eventData.documentId }, 'Document not found for webhook');
|
||||
return;
|
||||
}
|
||||
const doc = await resolveWebhookDocument(eventData.documentId, eventData.portId);
|
||||
if (!doc) return;
|
||||
|
||||
const [signer] = await db
|
||||
.select()
|
||||
@@ -1075,14 +1077,10 @@ export async function handleDocumentRejected(eventData: {
|
||||
documentId: string;
|
||||
recipientEmail?: string;
|
||||
signatureHash?: string;
|
||||
portId?: string;
|
||||
}) {
|
||||
const doc = await db.query.documents.findFirst({
|
||||
where: eq(documents.documensoId, eventData.documentId),
|
||||
});
|
||||
if (!doc) {
|
||||
logger.warn({ documensoId: eventData.documentId }, 'Document not found for webhook');
|
||||
return;
|
||||
}
|
||||
const doc = await resolveWebhookDocument(eventData.documentId, eventData.portId);
|
||||
if (!doc) return;
|
||||
|
||||
let signerId: string | null = null;
|
||||
if (eventData.recipientEmail) {
|
||||
@@ -1102,13 +1100,13 @@ export async function handleDocumentRejected(eventData: {
|
||||
await db
|
||||
.update(documents)
|
||||
.set({ status: 'rejected', updatedAt: new Date() })
|
||||
.where(eq(documents.id, doc.id));
|
||||
.where(and(eq(documents.id, doc.id), eq(documents.portId, doc.portId)));
|
||||
|
||||
if (doc.interestId && doc.documentType === 'eoi') {
|
||||
await db
|
||||
.update(interests)
|
||||
.set({ eoiStatus: 'rejected', updatedAt: new Date() })
|
||||
.where(eq(interests.id, doc.interestId));
|
||||
.where(and(eq(interests.id, doc.interestId), eq(interests.portId, doc.portId)));
|
||||
}
|
||||
|
||||
await db.insert(documentEvents).values({
|
||||
@@ -1128,25 +1126,21 @@ export async function handleDocumentRejected(eventData: {
|
||||
export async function handleDocumentCancelled(eventData: {
|
||||
documentId: string;
|
||||
signatureHash?: string;
|
||||
portId?: string;
|
||||
}) {
|
||||
const doc = await db.query.documents.findFirst({
|
||||
where: eq(documents.documensoId, eventData.documentId),
|
||||
});
|
||||
if (!doc) {
|
||||
logger.warn({ documensoId: eventData.documentId }, 'Document not found for webhook');
|
||||
return;
|
||||
}
|
||||
const doc = await resolveWebhookDocument(eventData.documentId, eventData.portId);
|
||||
if (!doc) return;
|
||||
|
||||
await db
|
||||
.update(documents)
|
||||
.set({ status: 'cancelled', updatedAt: new Date() })
|
||||
.where(eq(documents.id, doc.id));
|
||||
.where(and(eq(documents.id, doc.id), eq(documents.portId, doc.portId)));
|
||||
|
||||
if (doc.interestId && doc.documentType === 'eoi') {
|
||||
await db
|
||||
.update(interests)
|
||||
.set({ eoiStatus: 'cancelled', updatedAt: new Date() })
|
||||
.where(eq(interests.id, doc.interestId));
|
||||
.where(and(eq(interests.id, doc.interestId), eq(interests.portId, doc.portId)));
|
||||
}
|
||||
|
||||
await db.insert(documentEvents).values({
|
||||
|
||||
@@ -225,9 +225,13 @@ export async function listDocumensoWebhookSecrets(): Promise<DocumensoSecretEntr
|
||||
if (typeof row.value !== 'string' || !row.value || !row.portId) continue;
|
||||
out.push({ portId: row.portId, secret: row.value });
|
||||
}
|
||||
// Always include the global env secret as a fallback (null portId means
|
||||
// "no per-port resolution" — preserves single-tenant compatibility).
|
||||
out.push({ portId: null, secret: env.DOCUMENSO_WEBHOOK_SECRET });
|
||||
// Append the global env secret as a fallback ONLY when it's a real,
|
||||
// non-empty value. An empty env secret would otherwise match an empty
|
||||
// X-Documenso-Secret header (verifyDocumensoSecret guards this too,
|
||||
// but skipping the entry here keeps the matched-secret loop honest).
|
||||
if (env.DOCUMENSO_WEBHOOK_SECRET) {
|
||||
out.push({ portId: null, secret: env.DOCUMENSO_WEBHOOK_SECRET });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user