You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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,26no 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)
PR Checkout conversion: recovery emails, guest checkout lockdown, working promo codes #2141 (open, "Checkout conversion...guest checkout lockdown") fixes only guest-checkout/route.ts adds an allowedPriceIds() check against STRIPE_PLAN_IDS and
clamps quantity to 1-100. subscribe/route.ts and the desktop /subscribe endpoint are
untouched by that PR, and isProSubscription remains a deny-list.
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.
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.
Summary
priceIdandquantityare taken directly from client-supplied JSON and passed intostripe.checkout.sessions.createwith no allowlist, at three separate endpoints, despiteSTRIPE_PLAN_IDS(packages/utils/src/constants/plans.ts) existing for exactly this purpose:apps/web/app/api/settings/billing/guest-checkout/route.ts:9,26no auth at all.apps/web/app/api/settings/billing/subscribe/route.ts:14,68authenticated, but no allowlist.apps/web/app/api/desktop/[...route]/root.ts:702-791(POST /api/desktop/subscribe, used bydesktop + mobile) authenticated via
withAuth, butpriceIdis only validated asz.string(), no enum againstSTRIPE_PLAN_IDS.For contrast,
apps/web/app/api/v1/[...route]/route.ts:4402-4409does this correctly: itderives the price itself from
STRIPE_PLAN_IDS[environment][payload.interval]and nevertrusts 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 checksstripeSubscriptionStatus/thirdPartyStripeSubscriptionId.isProSubscriptionis a deny-list it excludes only SSO and signed-BAA subscriptions.apps/web/app/api/webhooks/stripe/route.ts:110-124,601-610),checkout.session.completedspecial-cases only SSO and signed-BAA subscriptions viaisSsoSubscription/isSignedBaaSubscription. Every other subscription i.e. any other liverecurring price on the account — falls into the generic path that sets
stripeSubscriptionStatus: subscription.statusandinviteQuotafrom 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: truewidens this further, and onsubscribe/route.tsandguest-checkout/route.tsan unboundedquantityflows straight intousers.inviteQuota.Existing related work (none of it closes this)
guest-checkout/route.tsadds anallowedPriceIds()check againstSTRIPE_PLAN_IDSandclamps quantity to 1-100.
subscribe/route.tsand the desktop/subscribeendpoint areuntouched by that PR, and
isProSubscriptionremains a deny-list.guest-checkoutonly doesn't preventa single request from buying Pro at an arbitrary price, and fails open on self-hosted deploys
without a configured Firewall rule.
Fix
priceIdagainstSTRIPE_PLAN_IDS[env]at all three endpoints (not justguest-checkout), mirroring the pattern already used in
v1/route.ts.quantityatsubscribe/route.tsand the desktop endpoint the same way Checkout conversion: recovery emails, guest checkout lockdown, working promo codes #2141 doesfor guest-checkout.
isProSubscriptionfrom a deny-list to an allow-list (only prices inSTRIPE_PLAN_IDSgrant Pro), so entitlement isn't dependent on catching every non-Pro priceindividually as it's created in Stripe.
guest-checkoutor 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.