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:
@@ -1,4 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
|
||||
import { env } from '@/lib/env';
|
||||
|
||||
@@ -19,8 +20,20 @@ import { env } from '@/lib/env';
|
||||
export function GET(req: NextRequest): Response {
|
||||
const expected = env.WEBSITE_INTAKE_SECRET;
|
||||
const provided = req.headers.get('x-intake-secret');
|
||||
// Use timingSafeEqual rather than a `===` comparison — string equality
|
||||
// is not constant-time and lets a remote attacker enumerate the secret
|
||||
// byte-by-byte via response-time differences.
|
||||
const matched =
|
||||
expected && provided && provided.length === expected.length && provided === expected;
|
||||
!!expected &&
|
||||
!!provided &&
|
||||
provided.length === expected.length &&
|
||||
(() => {
|
||||
try {
|
||||
return timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
if (!matched) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -62,13 +62,20 @@ export const PATCH = withAuth(async (req, ctx: AuthContext) => {
|
||||
if (body.phone !== undefined) updates.phone = body.phone;
|
||||
if (body.avatarUrl !== undefined) updates.avatarUrl = body.avatarUrl;
|
||||
if (body.preferences !== undefined) {
|
||||
const merged = {
|
||||
...((profile.preferences as Record<string, unknown>) ?? {}),
|
||||
...body.preferences,
|
||||
};
|
||||
// Hard cap on the merged JSONB to defend against historical rows
|
||||
// bloated by the previous .passthrough() schema. 8 KB is generous
|
||||
// — current legitimate keys are 3 booleans/strings.
|
||||
// Allow-list — only retain keys defined in the strict schema. Pre-
|
||||
// strict rows may carry extra keys from when the schema was
|
||||
// .passthrough(); the merge prunes them so legacy bloat doesn't
|
||||
// accumulate forever, and a future schema regression that tries
|
||||
// to ship arbitrary keys still gets dropped here at write time.
|
||||
const ALLOWED_PREF_KEYS = new Set(['dark_mode', 'locale', 'timezone']);
|
||||
const existing = (profile.preferences as Record<string, unknown>) ?? {};
|
||||
const merged = Object.fromEntries(
|
||||
Object.entries({ ...existing, ...body.preferences }).filter(([k]) =>
|
||||
ALLOWED_PREF_KEYS.has(k),
|
||||
),
|
||||
);
|
||||
// Hard cap on the merged JSONB — defense in depth against any
|
||||
// future schema growth that might re-introduce free-form keys.
|
||||
const serialized = JSON.stringify(merged);
|
||||
if (Buffer.byteLength(serialized, 'utf8') > 8 * 1024) {
|
||||
throw new ValidationError('preferences exceeds 8KB');
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user