Summary
An invited organization owner can purge the entire organization vault after reaching Accepted state, before any existing owner confirms them.
This is a server-side authorization bypass in the org-invite confirmation flow:
- accepting an invite moves the membership from
Invited to Accepted
- confirmation is a separate step that later upgrades
Accepted to Confirmed
- most privileged org routes correctly require
Confirmed
POST /api/ciphers/purge?organizationId=<org_id> does not; it only checks member.atype == Owner
As a result, a not-yet-confirmed owner can hard-delete every cipher and attachment in the organization.
Impact
This is a destructive authorization bypass on organization-wide ownership privileges.
A user who is intentionally still pending confirmation can:
- purge the entire organization vault
- hard-delete all organization ciphers
- delete all attachments linked to those ciphers
- cause immediate organization-wide data loss before the invite is confirmed
This breaks the security boundary that the Accepted -> Confirmed step is supposed to enforce.
Exploit Scenario
Assume:
- attacker controls an email address invited as an
Owner into Org A
- attacker is not yet confirmed by an existing owner
- attacker has a normal authenticated session on their own account
Steps:
- Attacker accepts the org invite.
Result: membership becomes Accepted, not Confirmed.
- Before any owner confirms them, attacker calls:
POST /api/ciphers/purge?organizationId=<org_id>
Authorization: Bearer <attacker_access_token>
Content-Type: application/json
{
"masterPasswordHash": "<attacker password hash>",
"otp": null
}
Expected behavior:
- request should be rejected because org-wide destructive owner actions should require a
Confirmed owner
Actual behavior:
- server looks up the membership
- server only checks
member.atype == Owner
- server deletes every cipher in the organization
- each cipher delete also removes attachments, collection links, favorites, and related records
Root Cause
Invite acceptance is intentionally separate from confirmation:
accept_org_invite() transitions Invited -> Accepted
_confirm_invite() transitions Accepted -> Confirmed
Privileged org guards reflect this design:
OwnerHeaders only accepts is_confirmed_and_owner()
But the org purge endpoint bypasses those guards entirely:
- it uses plain
Headers
- it loads the membership with
Membership::find_by_user_and_org()
- it authorizes solely on
member.atype == MembershipType::Owner
- it never checks
member.status == Confirmed
That means a pending owner receives full destructive owner power earlier than intended.
Code References
- Invite acceptance sets
Accepted:
src/api/core/mod.rs:263-279
- New-account setup also auto-runs invite acceptance:
src/api/core/accounts.rs:375-387
- Confirmation is a separate
Accepted -> Confirmed step:
src/api/core/organizations.rs:1384-1397
- Owner-only guards require confirmed status:
src/auth.rs:682-689
src/auth.rs:934-947
- Purge endpoint checks only owner type:
src/api/core/ciphers.rs:1645-1665
- Organization purge hard-deletes all ciphers:
src/db/models/cipher.rs:470-489
Why This Is Security-Relevant
This is not just an edge-case workflow mismatch.
The codebase clearly distinguishes:
Accepted: invite acknowledged, still pending trust/confirmation
Confirmed: fully activated org role
Using Headers plus member.atype == Owner collapses that distinction for one of the most destructive org-wide operations in the product.
If confirmation is meant to be a real trust gate, this endpoint bypasses it.
Fix Recommendation
Make org purge use the same confirmed-role model as the rest of privileged org APIs.
Concrete options:
- change the route to require
OwnerHeaders
- or explicitly reject when
member.status != MembershipStatus::Confirmed as i32
- audit other org-wide mutation endpoints that use
Membership::find_by_user_and_org() plus direct type checks instead of the confirmed-role request guards
Summary
An invited organization owner can purge the entire organization vault after reaching
Acceptedstate, before any existing owner confirms them.This is a server-side authorization bypass in the org-invite confirmation flow:
InvitedtoAcceptedAcceptedtoConfirmedConfirmedPOST /api/ciphers/purge?organizationId=<org_id>does not; it only checksmember.atype == OwnerAs a result, a not-yet-confirmed owner can hard-delete every cipher and attachment in the organization.
Impact
This is a destructive authorization bypass on organization-wide ownership privileges.
A user who is intentionally still pending confirmation can:
This breaks the security boundary that the
Accepted -> Confirmedstep is supposed to enforce.Exploit Scenario
Assume:
OwnerintoOrg ASteps:
Result: membership becomes
Accepted, notConfirmed.Expected behavior:
ConfirmedownerActual behavior:
member.atype == OwnerRoot Cause
Invite acceptance is intentionally separate from confirmation:
accept_org_invite()transitionsInvited -> Accepted_confirm_invite()transitionsAccepted -> ConfirmedPrivileged org guards reflect this design:
OwnerHeadersonly acceptsis_confirmed_and_owner()But the org purge endpoint bypasses those guards entirely:
HeadersMembership::find_by_user_and_org()member.atype == MembershipType::Ownermember.status == ConfirmedThat means a pending owner receives full destructive owner power earlier than intended.
Code References
Accepted:src/api/core/mod.rs:263-279src/api/core/accounts.rs:375-387Accepted -> Confirmedstep:src/api/core/organizations.rs:1384-1397src/auth.rs:682-689src/auth.rs:934-947src/api/core/ciphers.rs:1645-1665src/db/models/cipher.rs:470-489Why This Is Security-Relevant
This is not just an edge-case workflow mismatch.
The codebase clearly distinguishes:
Accepted: invite acknowledged, still pending trust/confirmationConfirmed: fully activated org roleUsing
Headersplusmember.atype == Ownercollapses that distinction for one of the most destructive org-wide operations in the product.If confirmation is meant to be a real trust gate, this endpoint bypasses it.
Fix Recommendation
Make org purge use the same confirmed-role model as the rest of privileged org APIs.
Concrete options:
OwnerHeadersmember.status != MembershipStatus::Confirmed as i32Membership::find_by_user_and_org()plus direct type checks instead of the confirmed-role request guards