Skip to content

Cross-Org Group Binding Enables Unauthorized Read And Write Access Into Another Organization

High
dani-garcia published GHSA-569v-845w-g82p Apr 25, 2026

Package

cargo vaultwarden (Rust)

Affected versions

<=1.35.4

Patched versions

1.35.5

Description

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:

  1. 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.
  2. 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

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
High
User interaction
None
Scope
Changed
Confidentiality
High
Integrity
High
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:H/A:N

CVE ID

CVE-2026-43912

Weaknesses

Improper Authorization

The product does not perform or incorrectly performs an authorization check when an actor attempts to access a resource or perform an action. Learn more on MITRE.

Credits