HALO Knowledge Docs
Generated knowledge spine · research/lanes/03-security.md

05 · Security review

Severity-ranked findings with file:line evidence and fixes.

Generated from research/lanes/03-security.md · regenerate with /opt/homebrew/opt/node@24/bin/node site/scripts/generate-docs.mjs

The work is complete — every finding was read from source. Resending the full deliverable below.

One correction first, on a number you say you'll report regardless: the split is 221 public vs 22 internal, not 456 vs 22. The 456 figure comes from counting the substring query(, which sweeps in database calls. Decomposed on the new path just now: of 308 query( hits, 109 are ctx.db.query(, 126 are other .query( chains, and only 73 are actual export const … = query( declarations. Your mutation (132) and action (16) counts were exactly right — those substrings have no ctx.db. equivalent to contaminate them. So: 73+132+16 = 221 public, 8+9+5 = 22 internal. Please use 221; the direction is unchanged (91% of the surface is public), but a number he can re-check in ten seconds should survive the check.


Halo CRM — Defensive Security Review

Static source review only. No live endpoint contacted. Repo root: /Users/shaansisodia/SISO_Workspace/SISO_Agency/clients/halocrm/repo. All paths repo-relative. Every count re-verified against source immediately before writing.

Findings

#SeverityStatusFindingEvidence
1CriticalCONFIRMEDresetAndSeed is a public mutation deleting all rows across 42 tables — one unauthenticated call wipes the businessconvex/resetHalo.ts:84
2CriticalCONFIRMEDZero server-side auth anywhere. ctx.auth/getUserIdentity appears 0 times in all 40 files. 221 public functions callable unauthenticatedconvex/*.ts
3CriticalCONFIRMEDThird-party creator credentials stored plaintext, readable via unauthenticated public queryconvex/schema.ts:502, convex/socialMediaLogins.ts:20
4HighCONFIRMEDSession is unsigned JSON in localStorage containing role; restored and trusted verbatim. Edit it → Adminsrc/context/SupabaseAuthContext.tsx:320-324
5HighCONFIRMEDVault gate client-side only: unsalted SHA-256, hash served publicly, hardcoded fallback HaloVault2026!src/hooks/useSecurePasswordManager.ts:43-47, convex/secureArea.ts:7
6HighCONFIRMEDSingle-round salted SHA-256 password hashing; no rate limiting or lockout on loginconvex/haloAuth.ts:4-9,28
7HighCONFIRMEDFive employee password hashes + salts committed in cleartext sourceconvex/resetHalo.ts:6-38
8MediumCONFIRMEDOnly role helper in the backend trusts a client-supplied actorId — caller declares own identityconvex/gamification.ts:185, 15 call sites
9MediumCONFIRMED7 more public seed/demo mutations mutating or clearing live dataseed.ts:6, demoSeed.ts:121, demoFixes.ts:36, gamificationSeed.ts:57,126, referralDemo.ts:23,236
10MediumCONFIRMED/convex-status and /tasks-rewards/* render outside ProtectedRoutesrc/App.tsx:144,180-183
11MediumCONFIRMEDAuth0 domain + clientId hardcoded as fallbackssrc/main.tsx:13-14
12LowCONFIRMEDNo security headers in vercel.jsonvercel.json
13LowCONFIRMEDxlsx@0.18.5 — unpatched prototype-pollution/ReDoS advisoriespackage.json:73
14LowCONFIRMEDRoute-memory + error-boundary soft lockout (availability, not security)src/components/auth/ProtectedRoute.tsx:30, src/App.tsx:137

Good news, verified: VITE_RUNPOD_API_KEY is in .env.example but read nowhere in src/ — Vite only inlines referenced vars, so it is not in the bundle and the key is not burned. The only six VITE_ vars actually read are VITE_CONVEX_URL, VITE_SUPABASE_URL, VITE_SUPABASE_PUBLISHABLE_KEY, VITE_DISABLE_SUPABASE, VITE_AUTH0_DOMAIN, VITE_AUTH0_CLIENT_ID — all legitimately public identifiers. No genuine secret is client-exposed. Real secrets are correctly server-side: ELEVENLABS_API_KEY (convex/voice.ts:48), FREECURRENCY_API_KEY (convex/currency.ts:114), Drive creds (convex/driveClient.ts:32). Contract/invite tokens use crypto.getRandomValues at 160/144 bits (convex/contracts.ts:6, convex/creatorInvitations.ts:6) — not guessable — and signing is single-use. Only one dangerouslySetInnerHTML, in shadcn's chart component injecting CSS vars, not user data (src/components/ui/chart.tsx:79).


1. CONFIRMED Critical — Public resetAndSeed wipes the business

convex/resetHalo.ts:84 is export const resetAndSeed = mutation({ args: {}, ... }). Not internalMutation. In Convex, mutation means publicly callable by anyone with the deployment URL — which ships in the bundle as VITE_CONVEX_URL. The handler (lines 86-91) loops the 42-table allTables array (lines 39-82) calling ctx.db.delete on every document: creators, contracts, payment records, payroll, invoicing, the credential vault, auth users. Empty args, no confirmation, no environment guard, no identity check.

Fix: change mutationinternalMutation at line 84 (and renameKishanToKeyshawn, line 180), importing from ./_generated/server. Internal functions are unreachable from the public API. Confirm Convex snapshot/backup retention is on as a safety net.

2. CONFIRMED Critical — No server-side authentication at all

Grepped every convex/ file for ctx.auth and getUserIdentity: 0 occurrences across 40 files. Exported-declaration counts:

KindPublicInternal
query738
mutation1329
action165
Total22122

The UI is the only gate, and on an SPA that is cosmetic — the deployment URL is in the bundle and the generated API is discoverable. Worst offenders, all unauthenticated public queries returning bulk sensitive data:

  • convex/paymentLog.ts:77 ledger — payment records
  • convex/payroll.ts:41,53,65 — salary/sales data
  • convex/invoicing.ts:166 all, :177 outstandingSummary
  • convex/creators.ts:68 list — creator personal data
  • convex/accessUsers.ts:25 list — accounts and roles
  • convex/contracts.ts:38 list — signed contracts
  • convex/socialMediaLogins.ts:20 — plaintext credentials

Fix: adopt Convex Auth or Clerk so ctx.auth.getUserIdentity() returns a verified identity, then add one helper — const requireUser = async (ctx) => { const id = await ctx.auth.getUserIdentity(); if (!id) throw new Error("Unauthorized"); return id; } — called at the top of every public function. Full coverage is multi-week; the seven above stop the bulk exposure.

3. CONFIRMED Critical — Credential vault stores plaintext

You asked me to focus here. convex/schema.ts:502 defines password: optionalString on socialMediaLogins — plain field, no encryption. convex/socialMediaLogins.ts:57 saveAll writes password: login.password ?? "" verbatim (line 84). formatLogin (lines 8-18) returns password: login.password ?? "" to any caller of the public listByCreatorEmail query (line 20).

So creators' OnlyFans, Instagram, Snapchat, TikTok and Twitter credentials sit cleartext in the database, retrievable by an unauthenticated call taking only a creator's email as argument. addPlatform (line 105) and remove (line 134) are likewise public. No key, no per-tenant scoping, nothing to encrypt with. This is the highest-consequence finding for the creators personally — account-takeover material for their income sources.

Fix: encrypt at rest with AES-GCM inside a Convex action, master key in a Convex env var (never VITE_), so plaintext never leaves the server except to an authenticated authorised caller. Encryption alone is insufficient while the decrypting query stays public — pair with finding 2. Because these must be assumed already exposed, rotate every stored third-party password after the fix ships.

4. CONFIRMED High — Self-asserted identity in localStorage

src/context/SupabaseAuthContext.tsx:320-324 reads haloLocalAuthSession and calls activateHaloLocalSession(JSON.parse(storedSession)) with no verification; that function (line 91) sets userRole/userRoles straight from the parsed object. convex/haloAuth.ts:12 login returns a profile but issues no token — nothing signed, nothing verifiable later. Edit role to "Admin" in devtools, reload, full admin UI.

Fix: server-issued signed expiring session token, verified server-side on every call. Never derive authorisation from client-held state.

5. CONFIRMED High — Vault gate is decorative

convex/secureArea.ts:7 exposes activePasswordHash as a public query returning the vault password hash to anyone. src/utils/passwordUtils.ts:10-20 hashes with unsalted single-round SHA-256 — trivially reversible via rainbow tables for any common password. Verification is entirely in-browser (src/hooks/useSecurePasswordManager.ts:47), and the result is written to sessionStorage (line 63), which the user controls. Worst: lines 43-45 fall back to return password === "HaloVault2026!" when no hash is configured — a hardcoded password in the public bundle. savePasswordHash (convex/secureArea.ts:17) is also a public mutation, so anyone can reset the vault password.

Since the underlying data is reachable without passing the gate at all (finding 3), the gate protects nothing.

Fix: delete the activePasswordHash query and the hardcoded fallback; verify server-side in a mutation; gate the credential-reading query on server-verified re-authentication.

6. CONFIRMED High — Weak hashing, no rate limiting

convex/haloAuth.ts:4-9 is a single crypto.subtle.digest("SHA-256", bytes); line 28 computes sha256Hex(\${authUser.passwordSalt}:${args.password}\). Salt is per-user and 32 hex chars (convex/resetHalo.ts:9) — correct, and it defeats rainbow tables — but there is no key stretching.

Sizing it: one RTX 4090 does ~50 billion SHA-256/sec; bcrypt cost 12 does ~20 thousand/sec — roughly 2.5 million times slower to attack. Per user, on one GPU: 8-char lowercase+digits (36^8) in under a minute; 8-char full mixed-case+symbols (95^8) in ~37 hours; anything in a leaked-password wordlist, instant. The same password under bcrypt cost 12: ~10,000 years.

login (line 12) is a public mutation with no attempt counter or backoff, so online guessing against the live deployment is also unthrottled — no offline access needed.

Fix: move to the auth provider from finding 2 (handles hashing and throttling). Short-term stopgap: scrypt via a Convex action plus per-email attempt throttling in a Convex table.

7. CONFIRMED High — Employee hashes committed to the repo

convex/resetHalo.ts:6-38 embeds passwordSalt and passwordHash for five named employees at @haloagency.net (Dylan, Tim, Cam, Keyshawn, Nick). Given finding 6's speed and the salts sitting in the same file, treat these as already cracked for any password that isn't long and random — a rented-GPU-afternoon concern, not a theoretical one.

Fix: rotate all five passwords. Remove the literals — seed from Convex env vars or a one-time admin flow. History scrubbing optional; rotation is what matters.

8. CONFIRMED Medium — Role checks trust client-supplied identity

You asked whether role checks exist inside sensitive mutations. Answer: essentially no. The entire backend has exactly one permission helper — assertQuestManager (convex/gamification.ts:185), used at 15 call sites (lines 720, 751, 768, 812, 884, 895, 943, 1072, 1098, 1113, 1181, 1369, 1403, 1427, 1474) — and it is defeated by design: it takes actorId as a function argument. getProfile (line 175) loads whatever ID was passed and checks its roles, so passing any known Admin profile ID satisfies it.

Outside gamification, the only other role-shaped code is display logic: convex/accessUsers.ts:7 and convex/teamMembers.ts:21 normalise a role string for output; convex/payroll.ts:320 filters a list. None of the 132 public mutations — including every payment, payroll, invoicing, contract, and credential mutation — checks the caller's role at all. The two parallel permission systems (convex/rolePermissions.ts canonical ladder vs src/utils/permissionUtils.ts reading Supabase) are both purely render-gating; neither is enforced server-side.

Fix: derive actorId from ctx.auth.getUserIdentity() and drop it from args. Consolidate on one server-enforced model.

9. CONFIRMED Medium — Seven more public seed/demo mutations

All verified individually as plain public mutation: convex/seed.ts:6 bootstrap; convex/demoSeed.ts:121 resetAndSeedFullDemo; convex/demoFixes.ts:36 applyLatestDemoFixes; convex/gamificationSeed.ts:57 seedGame and :126 assignStarterQuests; convex/referralDemo.ts:23 seedDemoMonth and :236 clearDemoMonth. clearDemoMonth and resetAndSeedFullDemo are destructive; the rest inject into live tables.

Fix: convert all to internalMutation.

10. CONFIRMED Medium — Public routes

You asked about four specifically (src/App.tsx):

  • /convex-status (line 144) — outside ProtectedRoute. Exposes deployment/connection status to anyone. Low direct harm, but it confirms the backend URL and liveness. Move behind auth.
  • /tasks-rewards/* (lines 180-183) — all four render TasksRewardsRoute, not wrapped in ProtectedRoute, including /tasks-rewards/control-panel. Given finding 8 (the quest-manager check trusts a client-supplied actorId), the admin control panel is both publicly routable and backed by defeatable checks. Wrap in ProtectedRoute and fix the identity source.
  • /invitation (line 143) — public by design, takes no token param; the token-bearing flow is /onboard/:token (line 191). Tokens are 144-bit CSPRNG (convex/creatorInvitations.ts:6-9) — not guessable. Schema carries expiresAt, and listPending (line 23) filters out already-submitted tokens, so reuse is bounded. This one is largely fine.
  • /upload/:id (line 170) — public and parameterised by a raw creator document _id, not a random token. Convex IDs aren't sequential, but they are not secrets either: any _id leaked through the many public queries in finding 2 becomes a working upload URL. Move to a dedicated expiring token like the contract flow uses.

Also public: / (139), /login (140), /forgot-password (141), /reset-password (142), /contracts/sign/:token (179, correctly token-gated and single-use per convex/contracts.ts:158), /onboarding-form[/:token] (189-190), and two redirect shims (192-193).

11-14. CONFIRMED Medium/Low

Auth0 fallbacks (src/main.tsx:13-14): clientIds are public by design, so not a leaked secret — but the fallback silently points at a dev tenant (dev-j4xj7bggmr0zhlid) if the env var is missing. Fail loudly instead.

Security headers (vercel.json): only buildCommand, outputDirectory, and a SPA rewrite. Add Content-Security-Policy, Strict-Transport-Security, X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin. Cheap, low-risk defence-in-depth.

xlsx@0.18.5 (package.json:73): prototype-pollution and ReDoS advisories with no fixed npm release. Move to the vendor-hosted build or exceljs if parsing untrusted uploads.

Availability, not security: useRouteMemory in ProtectedRoute (src/components/auth/ProtectedRoute.tsx:30) persists lastVisitedRoute. If a route crashes into RouteErrorBoundary (src/App.tsx:137), the saved route can restore the user straight back into the crashing page — a soft lockout that reads as "the app is down." Clear route memory when the boundary catches.


The three things to fix this week

  1. Convert the 9 seed/reset functions to internalMutationresetHalo.ts:84,180, seed.ts:6, demoSeed.ts:121, demoFixes.ts:36, gamificationSeed.ts:57,126, referralDemo.ts:23,236. One word each; removes the one-call-wipe risk today. Highest value per minute in the review.
  1. Real auth provider + requireUser(ctx) on the seven sensitive endpointspaymentLog.ts:77, payroll.ts:41, invoicing.ts:166, creators.ts:68, accessUsers.ts:25, contracts.ts:38, socialMediaLogins.ts:20. Full 221-function coverage is multi-week; these seven stop the bulk data exposure now.
  1. Encrypt the credential vault and rotate everything in it — plus the five employee passwords from convex/resetHalo.ts:6-38. Assume both sets are compromised. Rotation is independent of the code fix and can start immediately.

Not observable statically — worth him checking directly: whether Convex backups are enabled; whether the deployment has any network restriction; and whether anything has already been called. He should review the Convex dashboard function logs for calls to resetHalo.resetAndSeed and socialMediaLogins.listByCreatorEmail from unexpected sources.

Canonical source remains research/lanes/03-security.md. This HTML is a generated projection; edit the source, then run generate-docs.mjs.