Admin User Management — Null-Safety in deleteByIds and sanitizeUser#
Overview#
The admin user service (packages/core/admin/server/src/services/user.ts) exposes two null-safety vulnerabilities that can cause hard crashes and data-consistency problems: one in deleteByIds (batch deletion) and one in sanitizeUser (field redaction). Both are present as of Strapi 5.51.2.
Key Files#
| File | Role |
|---|---|
packages/core/admin/server/src/services/user.ts | Admin user service — deleteByIds, deleteById, sanitizeUser |
packages/core/admin/server/src/controllers/user.ts | HTTP controller — deleteOne, deleteMany |
The deleteByIds Null-Push Bug#
deleteByIds iterates over the provided IDs and calls strapi.db.query('admin::user').delete() for each one. When an ID matches no row, the DB query resolves to null. The result is pushed into deletedUsers unconditionally :
deletedUsers.push(deletedUser); // deletedUser can be null
After the loop, deletedUsers.map(sanitizeUser) crashes with:
TypeError: Cannot read properties of null (reading 'roles')
because sanitizeUser immediately accesses user.roles on line 44 .
The dangerous consequence: all DB deletes that ran before the crash have already committed. The batch-delete endpoint (POST /admin/users/batch-delete) returns HTTP 500 while the rows have been removed. Operators who see the failure may retry, compounding the problem .
This path is reachable from the admin UI under normal use: the Users list page does not auto-refresh after create/edit/delete operations, so stale cached selections can include IDs that no longer exist .
Contrast with deleteById: the single-user counterpart explicitly guards against a missing user before proceeding, returning null cleanly . The batch path lacks this same guard, an inconsistency confirmed in the open issue .
The sanitizeUser Null-Deref Issue#
sanitizeUser is typed to accept AdminUser (never null), but callers — notably deleteByIds — can pass a null at runtime. The spread _.omit(user, [...]) on line 37 survives a null input (lodash is permissive), but line 44:
roles: user.roles && user.roles.map(sanitizeUserRoles),
dereferences null and throws .
The TypeScript signature masks this at compile time: deleteByIds is declared as Promise<AdminUser[]> but the array can contain null entries because db.query().delete() is not typed as non-nullable .
Proposed Fixes#
The reporter in issue #27330 provides a minimal patch that resolves the crash and aligns batch behavior with deleteById:
-
Guard the push in
deleteByIds— skip null results so they are never added todeletedUsers:- deletedUsers.push(deletedUser); + if (deletedUser) { + deletedUsers.push(deletedUser); + } -
Defensive null check in
sanitizeUser— early-return for null/undefined inputs:+ if (!user) return user;
Fix (1) alone is sufficient to stop the crash; fix (2) is defence-in-depth for other callers that may inadvertently pass null .
Related: Self-Deletion Prevention#
PR #24739 (merged November 2025) added a separate guard: admins cannot delete themselves. Both the controller layer and the UI enforce this:
deleteOne— comparesctx.state.user.idwithctx.params.idand throwsApplicationError('You cannot delete your own user')before calling the service .deleteMany— uses aSetoverbody.idsand throws the same error if the current admin's ID is included .- UI — the delete button is hidden when
currentUser.id === user.id.
After deleteByIds returns, the controller's deleteMany maps the result through sanitizeUser without its own null guard , so the service-level null push is the sole point of failure.
Super-Admin Protection#
Both deleteById and deleteByIds enforce a minimum-one-super-admin rule before performing any deletions . This check runs against the live DB before any rows are touched, so it is not affected by the null-safety bug above.