# Bitwarden: Empty collections[] Skips Auth on POST /ciphers/import-organization
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 the Bitwarden series. This one is the most interesting of the three from a *research process* perspective — the bug itself is a one-line skip, but the path from "this is High integrity impact" to "this is Medium availability impact" went through a real triage debate about Bitwarden's zero-knowledge model. I want to walk that part honestly because it's the most instructive piece.
- ➜Vendor: Bitwarden
- ➜Product:
bitwarden/server - ➜Severity: Medium (CVSS 5.4) — adjusted down from initially-claimed High during triage
- ➜CVE: CVE-2026-43638
- ➜Report: HackerOne #3627482
- ➜Fix: bitwarden/server PR #7394 — commit `ebbf6dd0`
- ➜Fixed in: Self-hosted `v2026.4.1` (2026-04-20). Bitwarden Cloud deployed the night before the report was resolved on 2026-05-07.
//TL;DR
POST /ciphers/import-organization?organizationId={orgId} had this guard:
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 1 — src/Api/Tools/Controllers/ImportCiphersController.cs:
[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 2 — src/Core/Tools/ImportFeatures/ImportCiphersCommand.cs:
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 "exploit" actually looks like
The minimum viable request, with my non-member account's bearer token:
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.
The type: 2 (SecureNote) detail matters for a tactical reason explained below.
//The triage debate (this is the interesting part)
I initially scored this High (CVSS 7.1) with Integrity: High, claiming "attacker can inject arbitrary ciphers." The triager pushed back, correctly:
"Bitwarden's zero-knowledge model means org vault data is encrypted with the org's symmetric key, which is itself wrapped with each member's public key. A non-member attacker has no access to the org's encryption key, so any ciphers they inject would be encrypted with their own key — un-readable to legit org members. When org members or admins encounter these items, they'd see decryption failures, not intelligible content that could be used to phish a user." > — Bitwarden triager, HackerOne #3627482
That's right, and my "phishing via vault injection" claim was wrong. You can't impersonate org content because the encryption keys make impersonation literally impossible. Hand back to me.
There was also a separate problem: my PoC didn't work. I'd sent type: 1 (Login) without the login sub-object, which crashed CipherRequestModel.ToCipher() on a null deref *before* the cipher was stored. The triager saw a 500 and reasonably concluded "no items were actually imported." The bug was in my script, not the server.
I had to do two things to keep the report alive:
- 1.Fix the PoC. Switching to
type: 2(SecureNote) avoids theLogin.Urisaccess path entirely and the import succeeds with HTTP 200. The cipher really is persisted; you can confirm it in the Unassigned view. - 2.Re-frame the impact. Drop the integrity/phishing angle (it's wrong), and pivot to what *is* true:
- Storage exhaustion / DoS. ImportIntoOrganizationalVaultAsync performs 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; self-hosted skips both the cipher-count cap and rate limiting (if (!_globalSettings.SelfHosted && ...)). An unauthenticated-to-the-org attacker can write arbitrarily large blobs into the target org's storage.
- Vault pollution requiring manual cleanup. CipherOrganizationDetails_ReadUnassignedByOrganizationId returns *every* unassigned cipher with zero encryption validation. Garbage entries appear in admin views and have to be deleted by hand.
- Forced re-sync of every org member. CipherRepository.CreateAsync calls User_BumpAccountRevisionDateByOrganizationId, which bumps AccountRevisionDate for every confirmed member. Their clients then trigger a full vault re-sync.
The triager accepted the re-framing, dropped Integrity to Low ("garbage writes, not meaningful corruption"), kept Availability at Low, and noted that Bitwarden doesn't enforce a storage quota on org vaults — so the storage-exhaustion angle is bounded in practice. Final score: Medium (5.4), triaged.
The lesson I took out of this: *if your impact claim depends on the encryption model behaving a certain way, write that out explicitly in your initial report.* I assumed phishing-via-injection was implicit; the triager correctly forced me to articulate the model. The DoS framing is what the bug actually is, and it's what I should have led with.
//The fix
PR #7394, commit `ebbf6dd0`. Both layers were patched in the same change.
Controller — drop the empty-collections early return entirely:
- // 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:
+ 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-25 — Bitwarden requests more info: confirms the controller-level bypass via proxy intercept, but the original PoC's 500 obscured whether items were actually persisted, and challenges the zero-knowledge integrity claim.
- ➜2026-03-25 — Updated PoC (SecureNote payload), proof of HTTP 200 and persisted unassigned ciphers, and re-framed impact toward storage/DoS and forced re-sync.
- ➜2026-03-30 — Severity adjusted: Integrity dropped from High to Low (encrypted-with-attacker-key garbage, not meaningful corruption); Availability stays Low. 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 truereads 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.
- ➜Be willing to lose impact during triage. My initial Integrity:High framing was wrong. Bitwarden's zero-knowledge model genuinely prevents the phishing-via-injection scenario I'd written into the report. The right move was to acknowledge it, drop that claim, and articulate the actual primitive — a cross-tenant, by-GUID, unauthenticated-to-the-target write that bumps revisions and triggers re-syncs. Smaller impact, but real and verifiable.
- ➜Test your PoC against the production it's reporting on. A 500 from your own script is the worst possible signal to send a triager — it makes the bug look unreal even when it is.
type: 1failing onLogin.Uriswas avoidable by readingCipherRequestModel.ToCipher()before sending.
Thanks to @mandreko-bitwarden and the Bitwarden security team — the back-and-forth on this one made the report better.