Skip to content

v0.3.1 — Security fix: credential leak in inquiry/follow-up endpoints

Choose a tag to compare

@AshishGTH AshishGTH released this 18 Aug 06:05
· 73 commits to master since this release

First real pilot-user feedback, triaged and fixed. Three critical bugs
(hit hourly by a working sales team), two "check before building" items
that turned out to be backend-complete, and a security leak found along
the way.

Security

  • Credential leak, fixed: staff-facing inquiry and follow-up
    endpoints returned password hashes, TOTP secrets, and recovery codes
    to any authenticated staff user.
    GET /inquiries, GET /inquiries/:id, and GET /inquiries/:id/follow-ups embedded the
    full User row for assignedTo/createdBy — including
    passwordHash, totpSecret, and recoveryCodes — because a bare
    Prisma include: { relation: true } returns every scalar column on
    the related model, not just the ones the UI displays. Any staff user
    with read access to inquiries (not just admins) could see this data
    in their browser's network tab on every list/detail/follow-up load.
    Fixed by scoping both call sites (InquiryService.findAll/
    findOne, FollowUpService.findAllForInquiry) to explicit selects
    ({id, name, email} / {id, name}), verified absent both in a
    through-the-wire regression test and by inspecting raw response JSON
    on a live deployed instance. If you are running v0.3.0 or earlier,
    upgrade to v0.3.1 and rotate any staff password or TOTP secret you
    consider exposed
    — anyone who had inquiry-read access and inspected
    network traffic (or logs, if any client/proxy captured response
    bodies) during that window could have captured these values.

Fixed

  • A rep's own inquiry could silently reassign to admin. InquiryService.create()
    ran round-robin unconditionally whenever a project was set, with no
    notion of who created the inquiry — Inquiry had no createdById
    column at all. New default policy: an interactively-created inquiry is
    assigned straight to its creator; round-robin only runs for
    machine-driven intake (inbound lead API, bulk import), which has no
    human creator to retain ownership for. Configurable via
    CompanyConfig.presalesCreatorRetainsLead (default on).
  • Editing a user, for any field, always failed. UserForm.tsx's
    edit-mode submit sent a create-shaped payload (including email) to a
    .strict() update endpoint that never declared it — every save 400'd.
    A deeper client-side bug sat in front of it: the form's validation
    resolver was always createUserSchema, which requires password
    edit mode never renders that field, so submission failed silently at
    the validation layer before a request was ever sent. Both fixed; a new
    shared pickForSchema() helper (projects onto an update schema's own
    declared keys, rather than subtracting fields off a create-shaped
    object) closes the class of bug, applied at all three known sites
    (this one, Masters, Broker commission payment) plus a regression test
    covering every create/update schema pair this package exports.
  • Direct URL access to an admin page rendered the full page shell for
    any authenticated user
    , regardless of permission — the backend
    correctly 403'd the underlying data fetch, but nothing distinguished
    "no data yet" from "not allowed here." Every protected route now
    renders an access-denied screen instead when the user lacks the
    permission that gates it.

Added

  • Bulk Excel inquiry import now has a staff UI. The backend
    (row-level validation, applicant dedup, ExcelJS-based parsing) has
    existed since Phase 3 with no caller in apps/web — upload UI, a
    downloadable XLSX template (GET /inquiries/import-template, single
    source of truth shared with the parser's own header map), and
    per-row error reporting added to the Inquiries page.
  • Follow-up log now shows who logged it. FollowUp.createdById has
    been captured since Phase 3 and was already being fetched — just never
    rendered.