cd ../blog
cat /var/log/exploits/bitwarden-provider-takeover.md

# Bitwarden: Any Provider Could Take Over Any Organization (POST /clients/existing)

HIGH
May 2, 2026
[6 min read]
BitwardenBug BountyAuthorizationIDORHackerOneCVE-2026-43639

Bitwarden: Any Provider Could Take Over Any Organization

Two endpoints in the same controller. The GET filters by ownership. The POST does not. That's the whole bug.

This is a writeup of a missing-authorization vulnerability I reported to Bitwarden through HackerOne. The fix shipped in Bitwarden Server 2026.4.0, and the patch is public on GitHub — so I'm publishing the technical detail now.


//TL;DR

POST /providers/{providerId}/clients/existing accepted any organization GUID in the request body. The controller verified the caller was a provider service user — and stopped there. It never checked that the caller actually owned the target organization.

A provider could call the endpoint with someone else's organization ID and the server would happily:

  1. 1.Cancel the victim's Stripe subscription with InvoiceNow = true
  2. 2.Rewrite the billing email to the attacker's provider
  3. 3.Null out GatewaySubscriptionId and flip status to Managed
  4. 4.Link the victim org under the attacker's provider

Same controller has a sibling GET /clients/addable that *does* enforce ownership via SQL (WHERE OU.UserId = @UserId AND OU.Type = 0 — Owner only). The UI is built off that GET. The POST just trusted whatever GUID came in the body.


//The two endpoints

In src/Api/AdminConsole/Controllers/ProviderClientsController.cs, the GET that populates the "addable orgs" dropdown is correctly scoped to the calling user:

csharp
// GET /providers/{providerId}/clients/addable  — correctly filtered
var addable = await providerBillingService
    .GetAddableOrganizations(provider, userId);

Backed by `Organization_ReadAddableToProviderByUserId.sql`:

sql
WHERE
    OU.[UserId] = @UserId AND
    OU.[Type]   = 0  -- Owner
    OU.[Status] = 2  -- Confirmed
    O.[Enabled] = 1 AND
    O.[Status]  = 1  -- Created (not already managed)

The POST in the same file (vulnerable version):

csharp
[HttpPost("existing")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<IResult> AddExistingOrganizationAsync(
    [FromRoute] Guid providerId,
    [FromBody] AddExistingOrganizationRequestBody requestBody)
{
    var (provider, result) = await TryGetBillableProviderForServiceUserOperation(providerId);
    if (provider == null) return result;

    // Any GUID. No ownership check.
    var organization = await organizationRepository.GetByIdAsync(requestBody.OrganizationId);
    if (organization == null)
        return Error.BadRequest("The organization being added to the provider does not exist.");

    await providerBillingService.AddExistingOrganization(provider, organization, requestBody.Key);
    return TypedResults.Ok();
}

TryGetBillableProviderForServiceUserOperation resolves to CurrentContext.ProviderUser(providerId):

csharp
public bool ProviderUser(Guid providerId)
    => Providers?.Any(o => o.Id == providerId) ?? false;

That's *only* asking "are you a confirmed member of your own provider?" It says nothing about whether the org you just named in the body is yours.

The request model is similarly unhelpful — it validates presence, not authorization:

csharp
public class AddExistingOrganizationRequestBody
{
    [Required] public string Key { get; set; }
    [Required] public Guid OrganizationId { get; set; }
}

//What `AddExistingOrganization` actually does

The service method (bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs) is destructive *before* it ever crosses the auth boundary properly:

csharp
// 1. Cancel Stripe subscription with immediate invoicing
await stripeAdapter.UpdateSubscriptionAsync(organization.GatewaySubscriptionId,
    new SubscriptionUpdateOptions { CancelAtPeriodEnd = false });

var subscription = await stripeAdapter.CancelSubscriptionAsync(
    organization.GatewaySubscriptionId,
    new SubscriptionCancelOptions
    {
        InvoiceNow = true,
        Prorate = true,
        // ...
    });

// 2. Overwrite ownership-relevant fields
organization.BillingEmail          = provider.BillingEmail!;
organization.GatewaySubscriptionId = null;
organization.ExpirationDate        = null;
organization.MaxAutoscaleSeats     = null;
organization.Status                = OrganizationStatusType.Managed;

// 3. Link to provider
await providerOrganizationRepository.CreateAsync(new ProviderOrganization {
    ProviderId     = provider.Id,
    OrganizationId = organization.Id,
    Key            = key,
});

InvoiceNow = true is the part that turns this from "annoying" into "irreversible without vendor support." A misfire generates a final invoice on the victim's account at the moment of the call.


//Attack flow

text
1. Attacker has a provider (MSP/reseller) account on Bitwarden Cloud.
2. Attacker obtains the victim organization's GUID
   (member listings, public collections, prior relationship, leak, etc.).
3. POST /api/providers/{ATTACKER_PROVIDER_ID}/clients/existing
   {
     "organizationId": "<VICTIM_ORG_GUID>",
     "key": "<arbitrary>"
   }
4. ProviderUser(providerId) — passes (caller's own provider).
5. organizationRepository.GetByIdAsync(VICTIM_ORG_GUID) — returns the victim.
6. AddExistingOrganization runs end-to-end: subscription cancelled,
   billing email rewritten, status set to Managed, link created.

A practical caveat surfaced during triage: to *use* the organization (decrypt vault items) the attacker also needs the org's symmetric key. Without it the takeover is still real — subscription cancellation, billing rewrite, and the Managed flip all execute — but the data stays opaque. Anyone with prior legitimate access to the org likely already has that key.


//Why this slipped through

The shape of the bug is a fairly common pattern:

  • List endpoint is implemented as a query that's already filtered by the caller — ownership is implicit in the SQL.
  • Mutate endpoint trusts an ID from the body, on the assumption that "the UI only lets you pick from the filtered list anyway."

The UI assumption is the trap. The same controller was the right place to check, and the data layer already had a query that returned the correct set. The fix reuses it.


//The fix

PR #7372, commit `0918bfd`:

diff
- var (provider, result) = await TryGetBillableProviderForServiceUserOperation(providerId);
+ var userId = _currentContext.UserId;
+ if (!userId.HasValue) return Error.Unauthorized();
+
+ var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId);
  if (provider == null) return result;
+
+ if (!await _currentContext.OrganizationOwner(requestBody.OrganizationId))
+ {
+     return Error.Unauthorized();
+ }
+
+ var addableOrganizations = await organizationRepository
+     .GetAddableToProviderByUserIdAsync(userId.Value, provider.Type);
+ var organization = addableOrganizations
+     .FirstOrDefault(o => o.Id == requestBody.OrganizationId);
- var organization = await organizationRepository.GetByIdAsync(requestBody.OrganizationId);
  if (organization == null) return Error.NotFound();

Three layers of checking, all of which should have been there from day one:

  1. 1.TryGetBillableProviderForServiceUserOperationTryGetBillableProviderForAdminOperation (raises the role bar).
  2. 2.Explicit OrganizationOwner(orgId) check for the calling user.
  3. 3.The org has to come back from GetAddableToProviderByUserIdAsync — i.e. the same query the GET uses.

Tests landed alongside the fix in the same PR.


//Disclosure timeline

All times UTC.

  • 2026-03-25 — Report submitted to HackerOne (#3627889).
  • 2026-03-30 — Severity adjusted by Bitwarden: attack complexity raised to High (need target org GUID), privileges required raised to High (provider accounts are manually vetted).
  • 2026-03-31 — Triaged. Bitwarden notes the symmetric-key caveat for full data takeover.
  • 2026-04-02 — Patch lands on main (commit `0918bfd`, PR #7372).
  • 2026-04-21 — Fix released in Bitwarden Server 2026.4.0. Bounty awarded, report resolved.
  • 2026-05-02 — Public writeup.

//Takeaways

  • List filtering is not authorization. If your "addable items" endpoint filters by owner via SQL, the matching mutation endpoint must enforce that same filter server-side — not rely on the UI to pick from the filtered list.
  • Pair the two endpoints. When you see a GET /things/addable, look immediately for the POST that consumes one of those IDs. The asymmetry is the bug.
  • Service-user vs admin isn't the same as ownership. "Confirmed member of provider X" tells you the caller belongs to X — it tells you nothing about a target organization Y.
  • Destructive Stripe operations are an irreversibility multiplier. InvoiceNow = true upgrades a "logical takeover" finding into a "real money has moved" finding before the victim sees a notification.

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

@thesanjok

[EOF]