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
| # | Severity | Status | Finding | Evidence |
|---|---|---|---|---|
| 1 | Critical | CONFIRMED | resetAndSeed is a public mutation deleting all rows across 42 tables — one unauthenticated call wipes the business | convex/resetHalo.ts:84 |
| 2 | Critical | CONFIRMED | Zero server-side auth anywhere. ctx.auth/getUserIdentity appears 0 times in all 40 files. 221 public functions callable unauthenticated | convex/*.ts |
| 3 | Critical | CONFIRMED | Third-party creator credentials stored plaintext, readable via unauthenticated public query | convex/schema.ts:502, convex/socialMediaLogins.ts:20 |
| 4 | High | CONFIRMED | Session is unsigned JSON in localStorage containing role; restored and trusted verbatim. Edit it → Admin | src/context/SupabaseAuthContext.tsx:320-324 |
| 5 | High | CONFIRMED | Vault gate client-side only: unsalted SHA-256, hash served publicly, hardcoded fallback HaloVault2026! | src/hooks/useSecurePasswordManager.ts:43-47, convex/secureArea.ts:7 |
| 6 | High | CONFIRMED | Single-round salted SHA-256 password hashing; no rate limiting or lockout on login | convex/haloAuth.ts:4-9,28 |
| 7 | High | CONFIRMED | Five employee password hashes + salts committed in cleartext source | convex/resetHalo.ts:6-38 |
| 8 | Medium | CONFIRMED | Only role helper in the backend trusts a client-supplied actorId — caller declares own identity | convex/gamification.ts:185, 15 call sites |
| 9 | Medium | CONFIRMED | 7 more public seed/demo mutations mutating or clearing live data | seed.ts:6, demoSeed.ts:121, demoFixes.ts:36, gamificationSeed.ts:57,126, referralDemo.ts:23,236 |
| 10 | Medium | CONFIRMED | /convex-status and /tasks-rewards/* render outside ProtectedRoute | src/App.tsx:144,180-183 |
| 11 | Medium | CONFIRMED | Auth0 domain + clientId hardcoded as fallbacks | src/main.tsx:13-14 |
| 12 | Low | CONFIRMED | No security headers in vercel.json | vercel.json |
| 13 | Low | CONFIRMED | xlsx@0.18.5 — unpatched prototype-pollution/ReDoS advisories | package.json:73 |
| 14 | Low | CONFIRMED | Route-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 mutation → internalMutation 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:
| Kind | Public | Internal |
|---|---|---|
| query | 73 | 8 |
| mutation | 132 | 9 |
| action | 16 | 5 |
| Total | 221 | 22 |
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:77ledger— payment recordsconvex/payroll.ts:41,53,65— salary/sales dataconvex/invoicing.ts:166all,:177outstandingSummaryconvex/creators.ts:68list— creator personal dataconvex/accessUsers.ts:25list— accounts and rolesconvex/contracts.ts:38list— signed contractsconvex/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) — outsideProtectedRoute. 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 renderTasksRewardsRoute, not wrapped inProtectedRoute, including/tasks-rewards/control-panel. Given finding 8 (the quest-manager check trusts a client-suppliedactorId), the admin control panel is both publicly routable and backed by defeatable checks. Wrap inProtectedRouteand 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 carriesexpiresAt, andlistPending(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_idleaked 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
- Convert the 9 seed/reset functions to
internalMutation—resetHalo.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.
- Real auth provider +
requireUser(ctx)on the seven sensitive endpoints —paymentLog.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.
- 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.