Skip to content

Arbitrary Stripe priceId accepted at three checkout endpoints → Pro entitlement bypass #2222

Description

@addyCooks

Summary

priceId and quantity are taken directly from client-supplied JSON and passed into
stripe.checkout.sessions.create with no allowlist, at three separate endpoints, despite
STRIPE_PLAN_IDS (packages/utils/src/constants/plans.ts) existing for exactly this purpose:

  • apps/web/app/api/settings/billing/guest-checkout/route.ts:9,26 no auth at all.
  • apps/web/app/api/settings/billing/subscribe/route.ts:14,68 authenticated, but no allowlist.
  • apps/web/app/api/desktop/[...route]/root.ts:702-791 (POST /api/desktop/subscribe, used by
    desktop + mobile) authenticated via withAuth, but priceId is only validated as
    z.string(), no enum against STRIPE_PLAN_IDS.

For contrast, apps/web/app/api/v1/[...route]/route.ts:4402-4409 does this correctly: it
derives the price itself from STRIPE_PLAN_IDS[environment][payload.interval] and never
trusts a client-supplied price id.

Why this grants full Pro

Entitlement is checked by status, not by price:

  • userIsPro (packages/utils/src/lib/stripe/subscriptions.ts) only checks
    stripeSubscriptionStatus / thirdPartyStripeSubscriptionId.
  • isProSubscription is a deny-list it excludes only SSO and signed-BAA subscriptions.
  • In the Stripe webhook (apps/web/app/api/webhooks/stripe/route.ts:110-124,601-610),
    checkout.session.completed special-cases only SSO and signed-BAA subscriptions via
    isSsoSubscription/isSignedBaaSubscription. Every other subscription i.e. any other live
    recurring price on the account — falls into the generic path that sets
    stripeSubscriptionStatus: subscription.status and inviteQuota from the line-item quantity,
    with no price check at all.

So completing checkout with any other live recurring price in Cap's Stripe account (a
retired/legacy tier, an internal test price, anything not SSO/BAA) grants full Pro status.
allow_promotion_codes: true widens this further, and on subscribe/route.ts and
guest-checkout/route.ts an unbounded quantity flows straight into users.inviteQuota.

Existing related work (none of it closes this)

Fix

  1. Allowlist priceId against STRIPE_PLAN_IDS[env] at all three endpoints (not just
    guest-checkout), mirroring the pattern already used in v1/route.ts.
  2. Clamp quantity at subscribe/route.ts and the desktop endpoint the same way Checkout conversion: recovery emails, guest checkout lockdown, working promo codes #2141 does
    for guest-checkout.
  3. Convert isProSubscription from a deny-list to an allow-list (only prices in
    STRIPE_PLAN_IDS grant Pro), so entitlement isn't dependent on catching every non-Pro price
    individually as it's created in Stripe.
  4. Require auth on guest-checkout or otherwise bound its blast radius (currently zero auth).

Caveat

Actual historical exposure depends on which legacy/test prices are live (non-archived) in Cap's
Stripe account right now someone with Stripe dashboard access should check before sizing
impact. The code-level flaw is independent of that and reproducible today with any second live
recurring price.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions