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:
Matt Ciaccio
2026-05-05 21:03:31 +02:00
parent 4bab6de8be
commit 83239104e0
22 changed files with 402 additions and 176 deletions

View File

@@ -35,3 +35,53 @@ export const PREVIEWABLE_MIMES = new Set<string>([
'image/webp',
'application/pdf',
]);
/**
* Magic-byte signatures keyed by claimed MIME type. Used by the file
* upload handler to reject files whose first few bytes don't match the
* MIME the browser declared. Without this, a `<form>` could lie about
* Content-Type and pass arbitrary bytes through ALLOWED_MIME_TYPES.
*
* Each signature is the leading prefix of the file. When multiple variants
* exist (e.g. JPEG SOI + APPn marker), we accept any of them.
*/
export const MAGIC_BYTE_SIGNATURES: Record<string, Uint8Array[]> = {
'image/jpeg': [new Uint8Array([0xff, 0xd8, 0xff])],
'image/png': [new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])],
'image/gif': [
new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x37, 0x61]), // GIF87a
new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]), // GIF89a
],
'image/webp': [new Uint8Array([0x52, 0x49, 0x46, 0x46])], // RIFF; WEBP signature follows at offset 8
'application/pdf': [new Uint8Array([0x25, 0x50, 0x44, 0x46])], // %PDF
// Office formats are zip-based (modern: docx/xlsx) or OLE (legacy: doc/xls).
// Both share well-known magic bytes — match either family for a given MIME.
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': [
new Uint8Array([0x50, 0x4b, 0x03, 0x04]), // PK\3\4 (zip)
new Uint8Array([0x50, 0x4b, 0x05, 0x06]), // empty archive
],
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': [
new Uint8Array([0x50, 0x4b, 0x03, 0x04]),
new Uint8Array([0x50, 0x4b, 0x05, 0x06]),
],
'application/msword': [
new Uint8Array([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]), // OLE compound
],
'application/vnd.ms-excel': [new Uint8Array([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1])],
// text/plain and text/csv have no magic bytes — leave unconstrained;
// size cap + ALLOWED_MIME_TYPES allow-list is the only gate.
};
/** Returns true when the buffer starts with one of the registered prefixes
* for the given MIME, or when the MIME has no signature requirement. */
export function bufferMatchesMime(buffer: Buffer, mime: string): boolean {
const sigs = MAGIC_BYTE_SIGNATURES[mime];
if (!sigs) return true; // text/plain, text/csv, or unrecognised allow-list entry
return sigs.some((sig) => {
if (buffer.length < sig.length) return false;
for (let i = 0; i < sig.length; i++) {
if (buffer[i] !== sig[i]) return false;
}
return true;
});
}

View File

@@ -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);

View File

@@ -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 {

View File

@@ -1,5 +1,10 @@
import { z } from 'zod';
const httpUrl = z
.string()
.url()
.refine((u) => /^https?:\/\//i.test(u), 'must be an http(s) URL');
export const createPortSchema = z.object({
name: z.string().min(1).max(200),
slug: z
@@ -7,7 +12,7 @@ export const createPortSchema = z.object({
.min(1)
.max(100)
.regex(/^[a-z0-9-]+$/, 'Slug must be lowercase alphanumeric with hyphens'),
logoUrl: z.string().url().optional(),
logoUrl: httpUrl.optional(),
primaryColor: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
@@ -26,7 +31,7 @@ export const updatePortSchema = z.object({
.max(100)
.regex(/^[a-z0-9-]+$/, 'Slug must be lowercase alphanumeric with hyphens')
.optional(),
logoUrl: z.string().url().nullable().optional(),
logoUrl: httpUrl.nullable().optional(),
primaryColor: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)