fix(audit-tier-6): validation, perms, ops/infra, per-port webhook secret
Final audit polish — closes the remaining LOW + MED items the previous
tiers didn't reach:
* Validation hardening: me.preferences uses .strict() + 8KB cap
instead of unbounded .passthrough(); files.uploadFile gains
magic-byte verification (jpeg/png/gif/webp/pdf/doc/xlsx); OCR scan
endpoint enforces 10MB cap + magic-byte check on receipt images;
port logoUrl + me.avatarUrl reject javascript:/data: schemes via
a shared httpUrl refinement.
* Permission gates: document-sends/{brochure,berth-pdf} now require
email.send (was withAuth-only); document-sends/{preview,list} on
email.view; ai/email-draft on email.send; documents/[id]/send
uses send_for_signing (was create); expenses/export/parent-company
flips from hard isSuperAdmin to expenses.export for parity;
admin/users/options gated on reminders.assign_others (was withAuth).
* Envelope hygiene: auth/set-password switches the third {message}
variant to errorResponse + {data: {email}}; ai/email-draft wraps
jobId in {data: {jobId}}.
* UI polish: reports-list.handleDownload surfaces failures via
toastError (was console-only).
* Ops/infra: pin pnpm@10.33.2 across all three Dockerfiles +
packageManager field in package.json; Dockerfile.worker re-orders
user creation BEFORE pnpm install so node_modules / .cache dirs
are worker-owned (fixes tesseract.js + sharp EACCES at first PDF
parse); add Redis-ping HEALTHCHECK to the worker container.
* Public health endpoint: returns full env+appUrl payload only when
the caller presents X-Intake-Secret, otherwise a minimal {status}
so generic uptime monitors still work but anonymous internet
doesn't get deployment fingerprints.
* Per-port Documenso webhook secret: new system_settings key
+ listDocumensoWebhookSecrets() helper. The webhook receiver
iterates every configured per-port secret with timing-safe
comparison + falls back to env, then forwards the resolved portId
into handleDocumentExpired so two ports sharing a documensoId
cannot cross-mutate.
Deferred (handled in dedicated follow-up PRs):
* Tier 5.1 — direct service tests for portal-auth / users /
email-accounts / document-sends / sales-email-config. MED, large
test-writing scope.
* The {ok: true} → {data: null} envelope migration across
alerts/expenses/admin-ocr-settings/storage routes. Mechanical but
needs coordinated client + test updates.
* CSP-nonce migration (drop unsafe-inline) — needs middleware-level
nonce generation that the Next 15 router has to thread through.
* Idempotency-Key header on Documenso createDocument. Requires
schema column on documents to persist the key; deferred so it
doesn't bundle a migration into this commit.
* The 16 better-auth user_id FKs — separate dedicated migration
with care (some columns are NOT NULL today and cascade decisions
matter).
* PermissionGate / Skeleton / EmptyState wraps across 5 admin lists
(auditor-H §§36–37) and the residential-clients filter bar.
Test status: 1175/1175 vitest, tsc clean.
Refs: docs/audit-comprehensive-2026-05-05.md MED §§28,29,30 + LOW §§32–43
+ HIGH §9 (Documenso secrets follow-up).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
ALLOWED_MIME_TYPES,
|
||||
MAX_FILE_SIZE,
|
||||
PREVIEWABLE_MIMES,
|
||||
bufferMatchesMime,
|
||||
} from '@/lib/constants/file-validation';
|
||||
import { generateStorageKey, sanitizeFilename } from '@/lib/services/storage';
|
||||
import type { UploadFileInput, UpdateFileInput, ListFilesInput } from '@/lib/validators/files';
|
||||
@@ -44,6 +45,15 @@ export async function uploadFile(
|
||||
throw new ValidationError('File exceeds maximum size of 50MB');
|
||||
}
|
||||
|
||||
// Magic-byte verification — without this, the browser-declared MIME is
|
||||
// attacker-controlled and a malicious uploader could ship arbitrary
|
||||
// bytes through the ALLOWED_MIME_TYPES allow-list (auditor-E3 §27).
|
||||
// Berth-PDF and brochure paths already do this; the generic uploader
|
||||
// matches their guarantee here.
|
||||
if (!bufferMatchesMime(file.buffer, file.mimeType)) {
|
||||
throw new ValidationError(`File contents do not match the declared type '${file.mimeType}'`);
|
||||
}
|
||||
|
||||
const entity = data.entityType ?? 'general';
|
||||
const entityId = data.entityId ?? portId;
|
||||
const storagePath = generateStorageKey(portSlug, entity, entityId, file.mimeType);
|
||||
|
||||
@@ -36,6 +36,11 @@ export const SETTING_KEYS = {
|
||||
documensoClientRecipientId: 'documenso_client_recipient_id',
|
||||
documensoDeveloperRecipientId: 'documenso_developer_recipient_id',
|
||||
documensoApprovalRecipientId: 'documenso_approval_recipient_id',
|
||||
// Per-port Documenso webhook secret — two ports pointed at different
|
||||
// Documenso instances cannot share the global env secret. The receiver
|
||||
// resolves the matching port by trying each enabled secret with a
|
||||
// timing-safe comparison.
|
||||
documensoWebhookSecret: 'documenso_webhook_secret',
|
||||
eoiDefaultPathway: 'eoi_default_pathway',
|
||||
|
||||
// Branding
|
||||
@@ -192,6 +197,40 @@ export async function getPortDocumensoConfig(portId: string): Promise<PortDocume
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List every (portId, webhookSecret) pair configured across the platform,
|
||||
* plus a wildcard-port entry for the global env secret. The Documenso
|
||||
* webhook receiver iterates the list with `timingSafeEqual` until it
|
||||
* finds a match, then dispatches with the resolved portId.
|
||||
*
|
||||
* `null` portId in the returned array means "matches but no port was
|
||||
* resolved" — the caller falls back to the legacy global path.
|
||||
*/
|
||||
export interface DocumensoSecretEntry {
|
||||
portId: string | null;
|
||||
secret: string;
|
||||
}
|
||||
|
||||
export async function listDocumensoWebhookSecrets(): Promise<DocumensoSecretEntry[]> {
|
||||
const { db } = await import('@/lib/db');
|
||||
const { systemSettings } = await import('@/lib/db/schema/system');
|
||||
const { eq, isNotNull } = await import('drizzle-orm');
|
||||
const rows = await db
|
||||
.select({ portId: systemSettings.portId, value: systemSettings.value })
|
||||
.from(systemSettings)
|
||||
.where(eq(systemSettings.key, SETTING_KEYS.documensoWebhookSecret));
|
||||
void isNotNull; // imported for future filters
|
||||
const out: DocumensoSecretEntry[] = [];
|
||||
for (const row of rows) {
|
||||
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 });
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── Branding ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PortBrandingConfig {
|
||||
|
||||
Reference in New Issue
Block a user