Final audit pass on feat/berth-recommender (3 parallel Opus agents) caught 5 critical and ~12 high-severity findings. All addressed in-branch; medium/low items deferred to docs/audit-final-deferred.md. Critical: - Add filesystem-backend PUT handler at /api/storage/[token] so presigned uploads stop 405-ing in filesystem mode (every browser-driven berth-PDF + brochure upload was broken). Same token-verify + replay protection as GET, plus magic-byte gate when c=application/pdf. - Forward req.signal into streamExpensePdf so an aborted 1000-receipt export no longer keeps grinding for minutes. - Strengthen Content-Disposition filename sanitization: \s matches CR/LF which would let documentName forge headers; restrict to [\w. -]+ and add filename* RFC 5987 fallback. - Lock public berths feed behind an explicit slug allowlist instead of ?portSlug= enumeration. - Reject cross-port interest_berths upserts (defense-in-depth on top of the recommender SQL port filter). High: - Recommender: width-only feasibility now caps length via L/W ratio so a 200ft berth doesn't surface for a 30ft beam request; total_interest_count filters out junction rows whose interest is in another port. - Mooring normalization follow-up migration (0034) catches un-hyphenated padded forms (A01) the original 0024 WHERE missed. - Send-out rate limit moved AFTER validation and scoped per-(port, user) so typos don't burn a slot and a multi-port rep can't be DoS'd by another tenant. - Default-brochure path now blocks an archived row from sneaking through the partial unique index. - NocoDB import --update-snapshot honoured under --dry-run so reps can refresh the seed JSON without committing DB writes. - PDF export: orderBy desc(expenseDate); apply isNull(archivedAt) when expenseIds are passed (was bypassed); flag rate-unavailable rows with an amber footer instead of silently treating them as 1:1; skip the USD->EUR chain when source already matches target. - expense-form-dialog: revokeObjectURL captures the URL in the closure instead of revoking the still-displayed one; reset upload state on close. - scan/page: handleClearReceipt resets in-flight scan/upload mutations; Save disabled while upload pending. - updateExpense re-asserts receipt-or-acknowledgement at the merged row so PATCH can't slip past the create-time refine. Plus the in-progress receipt upload UI for the expense form dialog (receipt picker + "I have no receipt" checkbox + warning banner) and a noReceiptAcknowledged flag on ExpenseRow for edit-mode hydration. Includes the canonical plan doc (referenced in CLAUDE.md), the handoff prompt, and a deferred-findings index for follow-up issues. 1163/1163 vitest passing. Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4.4 KiB
Final audit deferred findings
The pre-merge audit on feat/berth-recommender produced ~30 findings. The
critical + high-severity items were fixed in-branch. The items below are
medium / low severity and deferred to follow-up issues so the merge isn't
held up. Each entry is self-contained — pick one off and ship it.
Cross-cutting integration
-
EOI in-app pathway silently swallows missing
Berth RangeAcroForm field —src/lib/pdf/fill-eoi-form.ts:93.setText(form, 'Berth Range', ...)is wrapped in a try/catch that succeeds silently when the field is absent. CLAUDE.md already warns ops about needing to add the field to the live Documenso template; this code change would make the deployment gap observable. Fix: whencontext.eoiBerthRangeis non-empty AND the field is absent, log at warn level + surface a structured response field. -
Email body merge expansion happens after token validation —
src/lib/services/document-sends.service.ts:399-403. If a merge value contains a{{token}}substring (e.g. a client name like"Acme {{discount}} Inc."), the expanded body will contain a token the unresolved-check missed and ships with literal braces. Fix: HTML- escape merge values before expansion, OR run a secondfindUnresolvedTokensagainst the expanded body. -
Filesystem dev-fallback HMAC secret can drift across processes —
src/lib/storage/filesystem.ts:328-331. The dev-only fallback derives the HMAC secret fromBETTER_AUTH_SECRET. Two CRM processes running with different secrets (web vs worker) reject each other's tokens. Fix: assertBETTER_AUTH_SECRETis set when filesystem backend is active in non-prod, or document the requirement loudly. -
Berth PDF apply path: numeric column nulling silently drops —
src/lib/services/berth-pdf.service.ts:473-475. WhenNumber.isFinite(n)is false the apply loopcontinues without pushing toappliedand without warning. Combined with the "no appliable fields supplied" check (only fires when ALL drop), partial silent drops are invisible. Fix: collect dropped keys and surface them.
Multi-tenant isolation hardening
-
document_sends row stores
interestIdwithout verifying port match —src/lib/services/document-sends.service.ts:422. Audit-log pollution rather than data exposure (the recipient lookup is port-checked already). Fix: whenrecipient.interestIdis set, fetch withand(eq(interests.id, ...), eq(interests.portId, input.portId))and throw if missing. -
Storage proxy token does not bind to port_id —
src/lib/storage/filesystem.ts:73-84. ProxyTokenPayload is{k, e, n, f?, c?}with a global HMAC. The current "issuer always checks port first" relies on every issuer being correct in perpetuity. Fix: add ap(portId) claim and have the proxy route resolve key→owner row + assertowner.portId === payload.pbefore streaming. -
Documenso webhook does not enforce port_id on document lookups —
src/app/api/webhooks/documenso/route.ts:96-148. Handlers dispatch by globaldocumensoId. If two ports' documents were ever issued the same Documenso ID (replay across staging/prod, forwarded webhook from a foreign instance), the wrong port's interest could be mutated. The per-bodysignatureHashdedup is partial mitigation. Fix: either (a) include the originating Documenso instance/team in the lookup, or (b) verifydocuments(documenso_id)has a unique index port-wide.
Recent expense work polish
- renderReceiptHeader cursor math drifts after multi-step writes —
src/lib/services/expense-pdf.service.ts:854. Afterdoc.text(...)with auto-flow,doc.yadvances. Usingdoc.y - headerH + 10after the rect+stroke block computes against the post-rect position; works only because pdfkit's text-after-rect hasn't moved y yet. Headers may misalign on the first receipt page after a soft page break. Fix: captureconst baseY = doc.ybefore drawing the rect and compute all subsequent offsets relative to it.
Settings parsing
loadRecommenderSettingsrejects string-shaped JSONB booleans —src/lib/services/berth-recommender.service.ts:116. Postgres returns JSONBtrue/falseas JS booleans, but if an admin saves"true"via a UI that wraps the value as a string,asBoolreturns null and the per-port override silently falls through to defaults. Not a security bug; a tuning footgun. Fix: accept"true"/"false"string forms inasBool.