Repository navigation
Re-check ghost status before an admin delete, and lock the last-admin guard #412
Copy link
Copy link
Closed
Labels
AuthSecuritySecurity, access control, and data exposureSecurity, access control, and data exposurebugSomething isn't workingSomething isn't working
Milestone
Description
Summary
Two admin-only safety gaps:
- Ghost-account delete acts on any user id the admin sends. The single and the bulk path never re-check that the user is still a ghost, and never refuse an admin. A hard delete cascades to
admin_users, which bypasses the last-admin guard. If the impact check fails, the single path treats the user as non-blocking and hard-deletes. The bulk path does the same, and also when the impact read throws. - The last-admin guard can race.
toggleAdminRolereads the admin count and deletes the row in two separate calls, with no lock. Two admins who remove each other at the same moment can leave zero admins.
Only an admin can trigger either one. This is a safety and data-loss issue, not an outside attack. The delete reads no cache; it trusts the id it is given.
- Severity: Low
- Area:
apps/hocuspocus.serveradmin API,admin_users - Source: security review of 2026-10-06, finding L14
Where
- Single delete route, no param schema:
apps/hocuspocus.server/src/api/routers/admin.router.ts:222. The unusedghostDeleteSchema(apps/hocuspocus.server/src/schemas/admin.schema.ts:97-99) names the fielduserId, but the route param is:id. - Controllers:
deleteGhostAccountandbulkDeleteGhostAccounts,apps/hocuspocus.server/src/api/controllers/admin-audit.controller.ts:351-381. - Service, single:
apps/hocuspocus.server/src/api/services/adminGhostAccounts.service.ts:276-315.fetchGhostDeletionImpactignores the RPC error (:249-255), so a failed check reads as "no blocking messages". - Service, bulk: same file
:324-401. An RPC error reads asnullthroughfetchGhostDeletionImpact. A thrown impact read also becomesimpact: null(:336-339). Thennullgoes to hard delete (:358-360). - Ghost rules:
classifyGhost, same file:73-92. - Hard delete cascades to
admin_users:packages/supabase/scripts/02-z-admin-users.sql:11-12. - Last-admin race:
toggleAdminRole,apps/hocuspocus.server/src/api/services/adminStats.service.ts:393-420(count at:405-408, delete at:410). The controller already refuses a change to the caller's own role (apps/hocuspocus.server/src/api/controllers/admin.controller.ts:135-137).
Fix plan
- Validate the route param. Rename the
ghostDeleteSchemafield fromuserIdtoid, and registerzValidator('param', ghostDeleteSchema, houseEnvelopeHook)onadmin.delete('/audit/ghost-accounts/:id', …).GhostDeleteInputfollows the schema, and nothing imports it. - Add one guard in
adminGhostAccounts.service.ts,assertDeletableGhost(client, userId), and call it per id in bothdeleteGhostAccountandbulkDeleteGhostAccounts. It refuses when:client.auth.admin.getUserById(userId)fails or finds no user;classifyGhost(user, 0)returnsnull, so the user is no ghost under any age filter;- the id has a row in
admin_users.
The caller is always an admin, so the admin refusal also covers a self-delete.
- Fail closed on the impact check.
fetchGhostDeletionImpactreturns{ error }when the RPC errors, likegetGhostDeletionImpactdoes.- The single path returns
{ status: 'error' }. - The bulk path counts that id as
failedand moves on. - Never fall through to hard delete.
- Make the revoke atomic in SQL. Add
public.admin_revoke_admin(p_user_id uuid) returns boolean.- It runs
lock table public.admin_users in share row exclusive mode, counts the rows, and refuses when the count is 1 or less. - Otherwise it deletes the row and returns
true. It returnsfalsewhen no row exists. security invoker. Revoke EXECUTEfrom public, anon, authenticatedand grant it toservice_role.toggleAdminRolecalls it throughsupabase.rpcin place of the count and the delete. Map a refusal to{ status: 'last_admin' }.
- It runs
- Repo rules for step 4:
- Add a migration under
packages/supabase/migrations/, and mirror the function inpackages/supabase/scripts/02-z-admin-users.sql. - Regenerate
seed.sqlwithbun run --filter @docs.plus/supabase_back seed. - Run
bun run --filter @docs.plus/supabase_back types. It adds the new function, so commit the regeneratedapps/webapp/src/types/supabase.ts.
- Add a migration under
Layer: the ghost rules stay in the service, beside classifyGhost. Do not move them into the controller, and do not add a ghost re-check to SQL. Only the count-and-delete moves to SQL, because only Postgres can lock it.
Out of scope
- Param validation on
/audit/ghost-accounts/:id/impact. It reads only. - Deleting an active account on request. Clear the public profile when an account is soft-deleted #427 treats the ghost delete as the tool for deletion requests from the privacy page. After this fix, that path refuses an active account. The dashboard only lists ghosts, so no screen loses a feature, but a direct API call does. Agree the deletion path with the Clear the public profile when an account is soft-deleted #427 owner before you merge.
- What a soft delete leaves on the profile. See Clear the public profile when an account is soft-deleted #427.
- Closing the deleted user's open sockets. See Re-check a WebSocket's identity when its access token expires #430.
Acceptance criteria
- Deleting a user who signed in since the list loaded is refused, on the single and the bulk path.
- Deleting an admin through either ghost path is refused.
- An impact-check error stops the delete. The single path returns an error, and the bulk path counts that id as failed.
- A non-uuid
:idgets the house400. - Two concurrent revokes of the last two admins leave at least one admin.
Verify
- Local stack: list ghosts in the admin dashboard, sign in once as one of them, then delete that user from the stale list. Expect a refusal.
- In two
psqlsessions on the local database,begin;in both, thenselect public.admin_revoke_admin('<A>');andselect public.admin_revoke_admin('<B>');, thencommit;both. Thenselect count(*) from public.admin_users;must return 1 or more. bun run --filter @docs.plus/hocuspocus typecheck.
Related
- Record the actor of every admin write action #404 (admin actor logging, fixed)
- Clear the public profile when an account is soft-deleted #427 (soft-deleted account keeps its profile)
- Re-check a WebSocket's identity when its access token expires #430 (open WebSocket after a ban)
Generated by Claude Code
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
AuthSecuritySecurity, access control, and data exposureSecurity, access control, and data exposurebugSomething isn't workingSomething isn't working
Activity
[-]Admin ghost-account delete acts on any id without re-checks, and the last-admin guard can race[/-][+]Re-check ghost status before an admin delete, and lock the last-admin guard[/+]fix(hocuspocus): re-check ghost accounts before delete and clear soft…
docs(openapi): match the spec to today's admin, email, media and heal…
docs(hocuspocus): sync server docs with the 9 October fixes
docs(repo): record the 9 Oct security and email batch in the changelo…
HMarzban commentedon Oct 9, 2026
Fixed and live.
ce2bfde30,7d92a7507bbdaae66c(production run 37976785981, green).20261009120400is applied, andadmin_revoke_admin(uuid)EXECUTE is false for anon and authenticated, true for service_role. The ghost delete re-reads each user, and an Auth error that is not a 404 now answers 500.