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

@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { errorResponse } from '@/lib/errors';
import { errorResponse, ValidationError } from '@/lib/errors';
import { consumeCrmInvite } from '@/lib/services/crm-invite.service';
import { enforcePublicRateLimit } from '@/lib/api/route-helpers';
@@ -15,27 +15,26 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
const limited = await enforcePublicRateLimit(req, 'portalToken');
if (limited) return limited;
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ message: 'Invalid request body' }, { status: 400 });
}
let body: unknown;
try {
body = await req.json();
} catch {
// Use {error} via errorResponse so the envelope matches every other
// route (auditor-F §32 — was emitting {message} as a third variant).
throw new ValidationError('Invalid request body');
}
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ message: parsed.error.errors[0]?.message ?? 'Invalid input' },
{ status: 400 },
);
}
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
throw new ValidationError(parsed.error.errors[0]?.message ?? 'Invalid input');
}
try {
const result = await consumeCrmInvite({
token: parsed.data.token,
password: parsed.data.password,
});
return NextResponse.json({ success: true, email: result.email });
return NextResponse.json({ data: { email: result.email } });
} catch (err) {
return errorResponse(err);
}

View File

@@ -1,18 +1,34 @@
import { NextResponse } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { env } from '@/lib/env';
/**
* GET /api/public/health
*
* Public-facing health probe. Used by the marketing-website server on
* startup to verify it's pointed at a CRM matching its own deployment
* env (plan §14.8 critical: prevent staging-website-talking-to-prod-CRM).
* Health probe used by the marketing-website server on startup to verify
* it's pointed at a CRM matching its own deployment env (plan §14.8
* critical: prevent staging-website-talking-to-prod-CRM).
*
* Returns the CRM's `NODE_ENV` and `APP_URL` so the website can do a
* strict equality check before serving any request.
* Auditor-K §41 flagged that the previous response disclosed `NODE_ENV`
* and `APP_URL` to anonymous internet — mirrors the website's own intake
* secret gate so we don't leak deployment fingerprints. When
* `WEBSITE_INTAKE_SECRET` is set and the caller presents the matching
* `X-Intake-Secret` header we return the full payload; otherwise return
* a minimal `{status:'ok'}` so generic uptime monitors still get a 200.
*/
export function GET(): Response {
export function GET(req: NextRequest): Response {
const expected = env.WEBSITE_INTAKE_SECRET;
const provided = req.headers.get('x-intake-secret');
const matched =
expected && provided && provided.length === expected.length && provided === expected;
if (!matched) {
return NextResponse.json(
{ status: 'ok', timestamp: new Date().toISOString() },
{ headers: { 'cache-control': 'no-store' } },
);
}
return NextResponse.json(
{
status: 'ok',

View File

@@ -1,25 +1,31 @@
import { NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { withAuth } from '@/lib/api/helpers';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { db } from '@/lib/db';
import { userPortRoles, userProfiles } from '@/lib/db/schema';
import { errorResponse } from '@/lib/errors';
export const GET = withAuth(async (_req, ctx) => {
try {
const rows = await db
.select({
id: userPortRoles.userId,
displayName: userProfiles.displayName,
})
.from(userPortRoles)
.innerJoin(userProfiles, eq(userPortRoles.userId, userProfiles.userId))
.where(eq(userPortRoles.portId, ctx.portId))
.orderBy(userProfiles.displayName);
// Sole consumer is the reminder-form's "assign to" picker, so gate on
// `reminders.assign_others` rather than letting any authed user
// enumerate every colleague's display name + user id at the port
// (auditor-A3 §6).
export const GET = withAuth(
withPermission('reminders', 'assign_others', async (_req, ctx) => {
try {
const rows = await db
.select({
id: userPortRoles.userId,
displayName: userProfiles.displayName,
})
.from(userPortRoles)
.innerJoin(userProfiles, eq(userPortRoles.userId, userProfiles.userId))
.where(eq(userPortRoles.portId, ctx.portId))
.orderBy(userProfiles.displayName);
return NextResponse.json({ data: rows });
} catch (error) {
return errorResponse(error);
}
});
return NextResponse.json({ data: rows });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -1,7 +1,7 @@
import { NextResponse } from 'next/server';
import { and, eq } from 'drizzle-orm';
import { withAuth } from '@/lib/api/helpers';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { db } from '@/lib/db';
import { systemSettings } from '@/lib/db/schema/system';
import { requestEmailDraft } from '@/lib/services/email-draft.service';
@@ -9,29 +9,37 @@ import { parseBody } from '@/lib/api/route-helpers';
import { requestDraftSchema } from '@/lib/validators/ai';
import { CodedError, errorResponse } from '@/lib/errors';
export const POST = withAuth(async (req, ctx) => {
try {
// Feature flag check
const flag = await db.query.systemSettings.findFirst({
where: and(eq(systemSettings.key, 'ai_email_drafts'), eq(systemSettings.portId, ctx.portId)),
});
if (flag?.value !== true) {
throw new CodedError('NOT_FOUND', {
internalMessage: 'AI email-draft feature flag disabled for this port',
// Gated on `email.send` — the draft endpoint spends OpenAI tokens and
// renders client/interest-scoped content; only roles permitted to send
// emails should be able to mint drafts (auditor-A3 §7).
export const POST = withAuth(
withPermission('email', 'send', async (req, ctx) => {
try {
// Feature flag check
const flag = await db.query.systemSettings.findFirst({
where: and(
eq(systemSettings.key, 'ai_email_drafts'),
eq(systemSettings.portId, ctx.portId),
),
});
if (flag?.value !== true) {
throw new CodedError('NOT_FOUND', {
internalMessage: 'AI email-draft feature flag disabled for this port',
});
}
const body = await parseBody(req, requestDraftSchema);
const { jobId } = await requestEmailDraft(ctx.userId, {
interestId: body.interestId,
clientId: body.clientId,
portId: ctx.portId,
context: body.context,
additionalInstructions: body.additionalInstructions,
});
return NextResponse.json({ data: { jobId } }, { status: 202 });
} catch (error) {
return errorResponse(error);
}
const body = await parseBody(req, requestDraftSchema);
const { jobId } = await requestEmailDraft(ctx.userId, {
interestId: body.interestId,
clientId: body.clientId,
portId: ctx.portId,
context: body.context,
additionalInstructions: body.additionalInstructions,
});
return NextResponse.json({ jobId }, { status: 202 });
} catch (error) {
return errorResponse(error);
}
});
}),
);

View File

@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { sendBerthPdf } from '@/lib/services/document-sends.service';
@@ -12,22 +12,24 @@ import { sendBerthPdfSchema } from '@/lib/validators/document-sends';
* Sends the active per-berth PDF version to a client recipient. The body
* markdown goes through the merge-field expander + sanitizer
* (`renderEmailBody`) before reaching nodemailer (§14.7 critical mitigation:
* body XSS).
* body XSS). Gated on `email.send` (auditor-A3 §4).
*/
export const POST = withAuth(async (req, ctx) => {
try {
const input = await parseBody(req, sendBerthPdfSchema);
const result = await sendBerthPdf({
portId: ctx.portId,
berthId: input.berthId,
recipient: input.recipient,
customBodyMarkdown: input.customBodyMarkdown,
sentBy: ctx.userId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
});
export const POST = withAuth(
withPermission('email', 'send', async (req, ctx) => {
try {
const input = await parseBody(req, sendBerthPdfSchema);
const result = await sendBerthPdf({
portId: ctx.portId,
berthId: input.berthId,
recipient: input.recipient,
customBodyMarkdown: input.customBodyMarkdown,
sentBy: ctx.userId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { sendBrochure } from '@/lib/services/document-sends.service';
@@ -11,21 +11,28 @@ import { sendBrochureSchema } from '@/lib/validators/document-sends';
*
* Sends a brochure (default or specified) to a client recipient. Same
* sanitization + audit-row pipeline as the berth-pdf endpoint.
*
* Gated on `email.send` so the lowest-privilege role at a port (which
* has only `email.view`) can no longer fire nodemailer at arbitrary
* recipients. The 50/user/hour rate-limit was previously the only
* restriction (auditor-A3 §4).
*/
export const POST = withAuth(async (req, ctx) => {
try {
const input = await parseBody(req, sendBrochureSchema);
const result = await sendBrochure({
portId: ctx.portId,
brochureId: input.brochureId,
recipient: input.recipient,
customBodyMarkdown: input.customBodyMarkdown,
sentBy: ctx.userId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
});
export const POST = withAuth(
withPermission('email', 'send', async (req, ctx) => {
try {
const input = await parseBody(req, sendBrochureSchema);
const result = await sendBrochure({
portId: ctx.portId,
brochureId: input.brochureId,
recipient: input.recipient,
customBodyMarkdown: input.customBodyMarkdown,
sentBy: ctx.userId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { previewBody } from '@/lib/services/document-sends.service';
@@ -12,20 +12,22 @@ import { previewBodySchema } from '@/lib/validators/document-sends';
* Renders a body for the dry-run UI without actually sending. Returns the
* sanitized HTML, the post-merge markdown, and the list of unresolved
* `{{tokens}}` so the UI can block submit until the rep fills them in
* (§14.7 mitigation).
* (§14.7 mitigation). Gated on `email.view` (auditor-A3 §4).
*/
export const POST = withAuth(async (req, ctx) => {
try {
const input = await parseBody(req, previewBodySchema);
const result = await previewBody(
ctx.portId,
input.documentKind,
input.recipient,
input.customBodyMarkdown ?? null,
{ berthId: input.berthId, brochureLabel: input.brochureId },
);
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
});
export const POST = withAuth(
withPermission('email', 'view', async (req, ctx) => {
try {
const input = await parseBody(req, previewBodySchema);
const result = await previewBody(
ctx.portId,
input.documentKind,
input.recipient,
input.customBodyMarkdown ?? null,
{ berthId: input.berthId, brochureLabel: input.brochureId },
);
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -1,23 +1,28 @@
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseQuery } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { listSends } from '@/lib/services/document-sends.service';
import { listSendsQuerySchema } from '@/lib/validators/document-sends';
export const GET = withAuth(async (req, ctx) => {
try {
const query = parseQuery(req, listSendsQuerySchema);
const data = await listSends({
portId: ctx.portId,
clientId: query.clientId,
interestId: query.interestId,
berthId: query.berthId,
limit: query.limit,
});
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
});
// Gated on `email.view` so the lowest-privilege role at a port can no
// longer enumerate which clients have received which brochures / berth
// PDFs (auditor-A3 §4).
export const GET = withAuth(
withPermission('email', 'view', async (req, ctx) => {
try {
const query = parseQuery(req, listSendsQuerySchema);
const data = await listSends({
portId: ctx.portId,
clientId: query.clientId,
interestId: query.interestId,
berthId: query.berthId,
limit: query.limit,
});
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -5,7 +5,10 @@ import { errorResponse } from '@/lib/errors';
import { sendForSigning } from '@/lib/services/documents.service';
export const POST = withAuth(
withPermission('documents', 'create', async (req, ctx, params) => {
// Use the dedicated `send_for_signing` permission rather than `create`,
// so a role with documents.create-only does not also gain the ability
// to dispatch a Documenso send (auditor-A3 §3).
withPermission('documents', 'send_for_signing', async (req, ctx, params) => {
try {
const doc = await sendForSigning(params.id!, ctx.portId, {
userId: ctx.userId,

View File

@@ -1,26 +1,29 @@
import { NextResponse } from 'next/server';
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { exportParentCompany } from '@/lib/services/expense-export';
import { listExpensesSchema } from '@/lib/validators/expenses';
export const POST = withAuth(async (req, ctx) => {
try {
requireSuperAdmin(ctx, 'expenses.export.parent-company');
// Gated on `expenses.export` for parity with sibling export routes
// (auditor-A3 §5). Hard `isSuperAdmin` check used to lock out port
// admins who held expenses.export = true.
export const POST = withAuth(
withPermission('expenses', 'export', async (req, ctx) => {
try {
const body = await req.json().catch(() => ({}));
const query = listExpensesSchema.parse(body);
const pdf = await exportParentCompany(ctx.portId, query);
const body = await req.json().catch(() => ({}));
const query = listExpensesSchema.parse(body);
const pdf = await exportParentCompany(ctx.portId, query);
return new NextResponse(Buffer.from(pdf), {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="expenses-parent-company-${Date.now()}.pdf"`,
},
});
} catch (error) {
return errorResponse(error);
}
});
return new NextResponse(Buffer.from(pdf), {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="expenses-parent-company-${Date.now()}.pdf"`,
},
});
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -30,8 +30,22 @@ export const POST = withAuth(
const formData = await req.formData();
const file = formData.get('file') as File | null;
if (!file) throw new ValidationError('A file is required');
// Hard 10 MB cap — without this any authenticated rep could grief
// their own port's AI budget by sending arbitrarily large images
// and burning OCR tokens (auditor-E3 §28).
const MAX_OCR_BYTES = 10 * 1024 * 1024;
if (file.size > MAX_OCR_BYTES) {
throw new ValidationError('Receipt image is too large (10 MB max).');
}
const buffer = Buffer.from(await file.arrayBuffer());
const mimeType = file.type || 'image/jpeg';
// Magic-byte gate so a forged Content-Type doesn't reach the OCR
// provider with arbitrary bytes.
const { bufferMatchesMime } = await import('@/lib/constants/file-validation');
const allowedOcrMimes = ['image/jpeg', 'image/png', 'image/webp'];
if (!allowedOcrMimes.includes(mimeType) || !bufferMatchesMime(buffer, mimeType)) {
throw new ValidationError('Unsupported receipt image type.');
}
const config = await getResolvedOcrConfig(ctx.portId);
// Tesseract.js (in-browser) is the default. The server only invokes

View File

@@ -5,20 +5,33 @@ import { withAuth, type AuthContext } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { db } from '@/lib/db';
import { userProfiles } from '@/lib/db/schema';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors';
import { z } from 'zod';
const updateProfileSchema = z.object({
displayName: z.string().min(1).max(200).optional(),
phone: z.string().nullable().optional(),
avatarUrl: z.string().url().nullable().optional(),
// Refuse `javascript:` / `data:` schemes — z.string().url() lets them
// through and `<a href={avatarUrl}>` would otherwise be a stored-XSS
// vector if any future renderer treated the value as a link.
avatarUrl: z
.string()
.url()
.refine((u) => /^https?:\/\//i.test(u), 'must be an http(s) URL')
.nullable()
.optional(),
// Strict allow-list — no `.passthrough()` here. The previous schema let
// arbitrary client-supplied keys survive validation and persist into
// `userProfiles.preferences` JSONB unbounded; auditor-E3 §28 caught this.
// Add new keys here as the UI surfaces them rather than letting the
// client mint them at will.
preferences: z
.object({
dark_mode: z.boolean().optional(),
locale: z.string().optional(),
timezone: z.string().optional(),
})
.passthrough()
.strict()
.optional(),
});
@@ -49,10 +62,18 @@ 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) {
updates.preferences = {
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.
const serialized = JSON.stringify(merged);
if (Buffer.byteLength(serialized, 'utf8') > 8 * 1024) {
throw new ValidationError('preferences exceeds 8KB');
}
updates.preferences = merged;
}
const [updated] = await db

View File

@@ -3,6 +3,7 @@ import { createHash } from 'crypto';
import { db } from '@/lib/db';
import { verifyDocumensoSecret } from '@/lib/services/documenso-webhook';
import { listDocumensoWebhookSecrets } from '@/lib/services/port-config';
import {
handleRecipientSigned,
handleDocumentCompleted,
@@ -11,7 +12,6 @@ import {
handleDocumentRejected,
handleDocumentCancelled,
} from '@/lib/services/documents.service';
import { env } from '@/lib/env';
import { logger } from '@/lib/logger';
// BR-024: Dedup via signatureHash unique index on documentEvents
@@ -49,9 +49,22 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
}
// Documenso v1.13 + 2.x send the secret in plaintext via X-Documenso-Secret.
// Resolve the matching port by trying each configured per-port secret
// (plus the global env fallback) with timing-safe comparison. The
// resolved portId, when non-null, is threaded into handleDocumentExpired
// so two ports sharing a documensoId can't cross-mutate (auditor-D §22).
const providedSecret = req.headers.get('x-documenso-secret') ?? '';
if (!verifyDocumensoSecret(providedSecret, env.DOCUMENSO_WEBHOOK_SECRET)) {
const secrets = await listDocumensoWebhookSecrets();
let matchedPortId: string | null = null;
let matched = false;
for (const entry of secrets) {
if (verifyDocumensoSecret(providedSecret, entry.secret)) {
matched = true;
matchedPortId = entry.portId;
break;
}
}
if (!matched) {
logger.warn({ providedLen: providedSecret.length }, 'Invalid Documenso webhook secret');
return NextResponse.json({ ok: false, error: 'Invalid secret' }, { status: 200 });
}
@@ -149,7 +162,12 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
break;
case 'DOCUMENT_EXPIRED':
await handleDocumentExpired({ documentId: documensoId });
// 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 } : {}),
});
break;
default: