Summary
Vaultwarden does not enforce that:
- a
groups_users.users_organizations_uuid entry belongs to the same organization as groups.groups_uuid, or
- a
collections_groups.collections_uuid entry belongs to the same organization as collections_groups.groups_uuid.
Multiple organization group-management endpoints accept arbitrary MembershipId and CollectionId values and persist them directly without verifying org consistency.
Separately, core collection/cipher access queries treat group relationships as authoritative based only on:
- the current user's
users_organizations.uuid, and
- the linked
groups_users / collections_groups rows,
but do not require the linked groups.organizations_uuid to match the organization currently being accessed.
This lets an attacker who is:
Admin in Organization A, and
- only a low-privileged member in Organization B
bind their Org B membership UUID into an Org A group, then use that foreign group relationship to gain unauthorized access to Org B vault data.
With an accessAll=true Org A group, the attacker can make /api/sync and /api/ciphers enumerate Org B ciphers. Once those unauthorized sync results reveal Org B collection IDs, the attacker can also bind those foreign collection IDs to the Org A group and turn the same flaw into write access over Org B items.
This is a real cross-org authorization flaw, not a config issue and not just an audit/UI integrity bug.
Affected Code
1. Group management endpoints accept foreign member IDs and collection IDs without org validation
Group create/update forwards attacker-controlled collections and users directly into add_update_group():
src/api/core/organizations.rs:2458-2487
src/api/core/organizations.rs:2489-2525
Inside add_update_group():
- each supplied
CollectionId is persisted as CollectionGroup with no find_by_uuid_and_org() check
- each supplied
MembershipId is persisted as GroupUser with no find_by_uuid_and_org() check
Relevant lines:
src/api/core/organizations.rs:2528-2545
The standalone member-replacement endpoint has the same problem:
src/api/core/organizations.rs:2701-2738
2. The DB schema only enforces foreign keys, not same-org consistency
The group support migrations only require that:
groups_users.groups_uuid references some group
groups_users.users_organizations_uuid references some membership
collections_groups.collections_uuid references some collection
collections_groups.groups_uuid references some group
But there is no composite constraint enforcing that those rows belong to the same organization.
Examples:
migrations/postgresql/2022-07-27-110000_add_group_support/up.sql:11-23
migrations/sqlite/2022-07-27-110000_add_group_support/up.sql:11-23
migrations/mysql/2022-07-27-110000_add_group_support/up.sql:11-23
3. Cipher enumeration trusts foreign group bindings during user sync
/api/sync loads all ciphers visible to the user using Cipher::find_by_user_visible():
src/api/core/ciphers.rs:116-145
That resolves to Cipher::find_by_user(), which:
- joins
users_organizations for the current user and the cipher's org
- joins
groups_users by users_organizations.uuid
- joins
groups
- grants access if
groups.access_all = true or a collections_groups row exists
But it never requires groups.organizations_uuid == users_organizations.org_uuid.
Relevant lines:
src/db/models/cipher.rs:795-825
4. Full sync also accepts foreign group-derived collection relationships
CipherSyncData::new() builds sync metadata using:
Cipher::get_collections_with_cipher_by_user() for per-cipher collection IDs
CollectionGroup::find_by_user() for group-derived collection permissions
Neither path enforces that the group belongs to the same org as the membership currently being used.
Relevant lines:
src/db/models/cipher.rs:1096-1133
src/db/models/group.rs:377-390
5. Unauthorized sync responses still return the full cipher payload
When Cipher::to_json() cannot derive access restrictions, it logs an internal assertion failure and sets UI flags, but it still returns the full cipher object including:
key
notes
fields
data
- the typed payload (
login / card / identity / etc.)
Relevant lines:
- fallback on missing access restrictions:
src/db/models/cipher.rs:174-180
- full cipher payload serialization:
src/db/models/cipher.rs:343-392
This matters because the attacker is already a legitimate low-privilege member of Org B, so they already possess their own Org B membership key material via the normal profile/org response:
src/db/models/organization.rs:504-530
So the unauthorized Org B cipher records returned by sync are still decryptable by the attacker's client.
6. Foreign collection bindings can be used to extend the bug into write access
Once the attacker learns Org B collection IDs from the unauthorized sync output, they can update the Org A group and attach those foreign collection IDs with write/manage flags.
The following helpers then trust those foreign group collection rows:
Collection::find_by_user_uuid() for collection visibility
Collection::is_writable_by_user() for write authorization
Collection::is_coll_manageable_by_user() for manage authorization
Cipher::get_group_collections_access_flags() for cipher write/manage derivation
Relevant lines:
src/db/models/collection.rs:225-265
src/db/models/collection.rs:408-443
src/db/models/collection.rs:516-560
src/db/models/cipher.rs:681-703
Cipher modification routes rely on cipher.is_write_accessible_to_user():
src/api/core/ciphers.rs:669-692
So after foreign collection IDs are linked into the attacker's Org A group, the attacker can turn the same cross-org bug into unauthorized writes against Org B ciphers in those collections.
Impact
This breaks the fundamental org-isolation boundary.
An attacker who is admin in one org can abuse that org's group-management APIs to elevate their access inside another org where they should only be a normal member.
Practical impact:
- unauthorized read of another organization's vault items through normal sync/list APIs
- exposure of the target org's cipher metadata and collection IDs
- unauthorized access to collection listings in the target org
- follow-on unauthorized write access once the attacker rebinds the leaked target collection IDs into the foreign group
In other words, org membership separation is no longer reliable once the same user account participates in multiple organizations and holds admin rights in at least one of them.
Exploit Scenario
Assume:
- the attacker account is
Admin in Org A
- the same attacker account is only a normal confirmed member in Org B
- groups are enabled
Stage 1: collect the target-org membership UUID
The attacker uses their legitimate Org B account/profile data and reads their own organizationUserId for Org B:
src/db/models/organization.rs:504
This gives them their Org B membership UUID.
Stage 2: create a foreign accessAll group binding
Using Org A admin privileges, the attacker creates or updates an Org A group with:
accessAll = true
users = [ <attacker's Org B membership UUID> ]
Because the backend never verifies that the supplied MembershipId belongs to Org A, the row is accepted and stored in groups_users.
Stage 3: pull Org B ciphers through sync
The attacker now authenticates normally as themselves and calls:
GET /api/sync
Authorization: Bearer <attacker_access_token>
During cipher enumeration, the join chain:
- starts from the attacker's real Org B
users_organizations row
- follows
groups_users using that Org B membership UUID
- lands on the attacker-controlled Org A group
- sees
groups.access_all = true
and wrongly treats that as group-based authorization for Org B ciphers.
The sync response now contains Org B ciphers that the attacker should not be able to access.
Stage 4: extend to writes
Those unauthorized sync results expose Org B collectionIds.
The attacker goes back to Org A group management and updates the same Org A group so its collections array contains those Org B collection IDs with:
readOnly = false
hidePasswords = false
manage = true
Because no org-consistency check exists on collections_groups, those foreign links are also accepted.
Subsequent write checks for Org B ciphers then consume those foreign group-derived collection permissions and allow unauthorized updates to Org B items.
Minimal PoC
1. Read your own target-org membership UUID
Use the normal profile/sync response and note the Org B organizationUserId.
2. Create a foreign accessAll group in Org A
POST /api/organizations/<ORG_A>/groups
Authorization: Bearer <org_a_admin_token>
Content-Type: application/json
{
"name": "cross-org-pivot",
"accessAll": true,
"externalId": null,
"collections": [],
"users": ["<ATTACKER_MEMBERSHIP_ID_IN_ORG_B>"]
}
Expected secure behavior:
- request should be rejected because the supplied membership does not belong to Org A
Actual behavior:
- request succeeds and stores a foreign
groups_users row
3. Read unauthorized Org B vault data
GET /api/sync
Authorization: Bearer <same_attacker_account_token>
Expected secure behavior:
- Org B results should only contain ciphers authorized by Org B membership/collection assignments
Actual behavior:
- Org B ciphers become visible because the Org A
accessAll group is incorrectly honored for the Org B membership row
4. Optional write extension
Take one leaked Org B collection ID from the unauthorized sync response and update the Org A group:
PUT /api/organizations/<ORG_A>/groups/<GROUP_A_ID>
Authorization: Bearer <org_a_admin_token>
Content-Type: application/json
{
"name": "cross-org-pivot",
"accessAll": false,
"externalId": null,
"collections": [
{
"id": "<LEAKED_ORG_B_COLLECTION_ID>",
"readOnly": false,
"hidePasswords": false,
"manage": true
}
],
"users": ["<ATTACKER_MEMBERSHIP_ID_IN_ORG_B>"]
}
Expected secure behavior:
- request should be rejected because the collection does not belong to Org A
Actual behavior:
- request succeeds, and subsequent cipher write checks can consume that foreign collection-group mapping
Root Cause
There are two compounding design bugs:
- Group management APIs persist attacker-supplied membership IDs and collection IDs without checking that they belong to the same organization as the group being modified.
- Group-based authorization queries assume that a
groups_users or collections_groups relationship is trustworthy if the foreign keys exist, but they do not enforce same-org matching when evaluating access.
Either problem alone is dangerous. Together, they create a clean cross-org privilege-escalation path.
Remediation
Any real fix should enforce org consistency in both layers:
- On every group-management write path, reject:
MembershipId values whose org_uuid does not equal the group's org
CollectionId values whose org_uuid does not equal the group's org
- Add database-level protections where possible:
- composite foreign keys or triggers enforcing same-org relationships for
groups_users and collections_groups
- Harden all group-based access queries so they also require:
groups.organizations_uuid == users_organizations.org_uuid, and
- for collection lookups, that the group and collection belong to the same org
- Consider failing closed in
Cipher::to_json() when access restrictions unexpectedly resolve to None during user sync, instead of still serializing the full cipher payload
Summary
Vaultwarden does not enforce that:
groups_users.users_organizations_uuidentry belongs to the same organization asgroups.groups_uuid, orcollections_groups.collections_uuidentry belongs to the same organization ascollections_groups.groups_uuid.Multiple organization group-management endpoints accept arbitrary
MembershipIdandCollectionIdvalues and persist them directly without verifying org consistency.Separately, core collection/cipher access queries treat group relationships as authoritative based only on:
users_organizations.uuid, andgroups_users/collections_groupsrows,but do not require the linked
groups.organizations_uuidto match the organization currently being accessed.This lets an attacker who is:
Adminin Organization A, andbind their Org B membership UUID into an Org A group, then use that foreign group relationship to gain unauthorized access to Org B vault data.
With an
accessAll=trueOrg A group, the attacker can make/api/syncand/api/ciphersenumerate Org B ciphers. Once those unauthorized sync results reveal Org B collection IDs, the attacker can also bind those foreign collection IDs to the Org A group and turn the same flaw into write access over Org B items.This is a real cross-org authorization flaw, not a config issue and not just an audit/UI integrity bug.
Affected Code
1. Group management endpoints accept foreign member IDs and collection IDs without org validation
Group create/update forwards attacker-controlled
collectionsandusersdirectly intoadd_update_group():src/api/core/organizations.rs:2458-2487src/api/core/organizations.rs:2489-2525Inside
add_update_group():CollectionIdis persisted asCollectionGroupwith nofind_by_uuid_and_org()checkMembershipIdis persisted asGroupUserwith nofind_by_uuid_and_org()checkRelevant lines:
src/api/core/organizations.rs:2528-2545The standalone member-replacement endpoint has the same problem:
src/api/core/organizations.rs:2701-27382. The DB schema only enforces foreign keys, not same-org consistency
The group support migrations only require that:
groups_users.groups_uuidreferences some groupgroups_users.users_organizations_uuidreferences some membershipcollections_groups.collections_uuidreferences some collectioncollections_groups.groups_uuidreferences some groupBut there is no composite constraint enforcing that those rows belong to the same organization.
Examples:
migrations/postgresql/2022-07-27-110000_add_group_support/up.sql:11-23migrations/sqlite/2022-07-27-110000_add_group_support/up.sql:11-23migrations/mysql/2022-07-27-110000_add_group_support/up.sql:11-233. Cipher enumeration trusts foreign group bindings during user sync
/api/syncloads all ciphers visible to the user usingCipher::find_by_user_visible():src/api/core/ciphers.rs:116-145That resolves to
Cipher::find_by_user(), which:users_organizationsfor the current user and the cipher's orggroups_usersbyusers_organizations.uuidgroupsgroups.access_all = trueor acollections_groupsrow existsBut it never requires
groups.organizations_uuid == users_organizations.org_uuid.Relevant lines:
src/db/models/cipher.rs:795-8254. Full sync also accepts foreign group-derived collection relationships
CipherSyncData::new()builds sync metadata using:Cipher::get_collections_with_cipher_by_user()for per-cipher collection IDsCollectionGroup::find_by_user()for group-derived collection permissionsNeither path enforces that the group belongs to the same org as the membership currently being used.
Relevant lines:
src/db/models/cipher.rs:1096-1133src/db/models/group.rs:377-3905. Unauthorized sync responses still return the full cipher payload
When
Cipher::to_json()cannot derive access restrictions, it logs an internal assertion failure and sets UI flags, but it still returns the full cipher object including:keynotesfieldsdatalogin/card/identity/ etc.)Relevant lines:
src/db/models/cipher.rs:174-180src/db/models/cipher.rs:343-392This matters because the attacker is already a legitimate low-privilege member of Org B, so they already possess their own Org B membership key material via the normal profile/org response:
src/db/models/organization.rs:504-530So the unauthorized Org B cipher records returned by sync are still decryptable by the attacker's client.
6. Foreign collection bindings can be used to extend the bug into write access
Once the attacker learns Org B collection IDs from the unauthorized sync output, they can update the Org A group and attach those foreign collection IDs with write/manage flags.
The following helpers then trust those foreign group collection rows:
Collection::find_by_user_uuid()for collection visibilityCollection::is_writable_by_user()for write authorizationCollection::is_coll_manageable_by_user()for manage authorizationCipher::get_group_collections_access_flags()for cipher write/manage derivationRelevant lines:
src/db/models/collection.rs:225-265src/db/models/collection.rs:408-443src/db/models/collection.rs:516-560src/db/models/cipher.rs:681-703Cipher modification routes rely on
cipher.is_write_accessible_to_user():src/api/core/ciphers.rs:669-692So after foreign collection IDs are linked into the attacker's Org A group, the attacker can turn the same cross-org bug into unauthorized writes against Org B ciphers in those collections.
Impact
This breaks the fundamental org-isolation boundary.
An attacker who is admin in one org can abuse that org's group-management APIs to elevate their access inside another org where they should only be a normal member.
Practical impact:
In other words, org membership separation is no longer reliable once the same user account participates in multiple organizations and holds admin rights in at least one of them.
Exploit Scenario
Assume:
Adminin Org AStage 1: collect the target-org membership UUID
The attacker uses their legitimate Org B account/profile data and reads their own
organizationUserIdfor Org B:src/db/models/organization.rs:504This gives them their Org B membership UUID.
Stage 2: create a foreign
accessAllgroup bindingUsing Org A admin privileges, the attacker creates or updates an Org A group with:
accessAll = trueusers = [ <attacker's Org B membership UUID> ]Because the backend never verifies that the supplied
MembershipIdbelongs to Org A, the row is accepted and stored ingroups_users.Stage 3: pull Org B ciphers through sync
The attacker now authenticates normally as themselves and calls:
During cipher enumeration, the join chain:
users_organizationsrowgroups_usersusing that Org B membership UUIDgroups.access_all = trueand wrongly treats that as group-based authorization for Org B ciphers.
The sync response now contains Org B ciphers that the attacker should not be able to access.
Stage 4: extend to writes
Those unauthorized sync results expose Org B
collectionIds.The attacker goes back to Org A group management and updates the same Org A group so its
collectionsarray contains those Org B collection IDs with:readOnly = falsehidePasswords = falsemanage = trueBecause no org-consistency check exists on
collections_groups, those foreign links are also accepted.Subsequent write checks for Org B ciphers then consume those foreign group-derived collection permissions and allow unauthorized updates to Org B items.
Minimal PoC
1. Read your own target-org membership UUID
Use the normal profile/sync response and note the Org B
organizationUserId.2. Create a foreign
accessAllgroup in Org AExpected secure behavior:
Actual behavior:
groups_usersrow3. Read unauthorized Org B vault data
Expected secure behavior:
Actual behavior:
accessAllgroup is incorrectly honored for the Org B membership row4. Optional write extension
Take one leaked Org B collection ID from the unauthorized sync response and update the Org A group:
Expected secure behavior:
Actual behavior:
Root Cause
There are two compounding design bugs:
groups_usersorcollections_groupsrelationship is trustworthy if the foreign keys exist, but they do not enforce same-org matching when evaluating access.Either problem alone is dangerous. Together, they create a clean cross-org privilege-escalation path.
Remediation
Any real fix should enforce org consistency in both layers:
MembershipIdvalues whoseorg_uuiddoes not equal the group's orgCollectionIdvalues whoseorg_uuiddoes not equal the group's orggroups_usersandcollections_groupsgroups.organizations_uuid == users_organizations.org_uuid, andCipher::to_json()when access restrictions unexpectedly resolve toNoneduring user sync, instead of still serializing the full cipher payload