>_SPENCERLABSECURITY · INFRASTRUCTURE SERVING FROM THE HOMELAB|0 INBOUND PORTS
GitHub
Assessed

Market-readiness review: five billing and signup holes in my own SaaS

A pre-launch review of Aegis CloudOps and Fishing Rod Terminal found that signup could join any tenant or choose a paid plan, checkout granted plans for free without Stripe, pay-as-you-go unlocked paid features with no card, and two products could charge each other's prices. All five were fixed and verified live. None had been used.

3 min read
Assessed

Scope

What was reviewed

Both products share one multi-tenant backend (the one that just moved into this lab). The review covered the paths a stranger can reach before paying anything: signup, plan selection, checkout and the billing switches on the account page. These are the paths ad traffic would hit first.

assumed: hostile clientproduction checked regression tests per fixthrowaway accounts deleted

F1 · High

Signup could join any tenant

The signup endpoint accepted an account_id in the request body. No screen ever sent one, because team invites join through their own token, but the API honoured it. A crafted request could create a user inside an existing customer's tenant, including the operator's own, and read its data.

✕ Root cause

A field meant for internal flows and tests was trusted from the public request. The UI never showed it, so nobody thought of it as an input.

F2 · Medium

Signup could pick a paid plan

The same endpoint took a plan field. Sending enterprise at signup created an enterprise account without paying.

F3 · High

Checkout granted plans for free when Stripe wasn't configured

Without Stripe keys, checkout fell back to a sandbox simulation that marked the plan as bought. That's useful in development. In production it meant any plan was free, because production didn't have Stripe keys yet either.

▲ The pattern

A dev convenience that fails open. When a dependency is missing, the safe behaviour is to refuse with a clear reason, not to pretend it succeeded.

F4 · Medium

Pay-as-you-go turned on with no card

Fishing Rod's pay-as-you-go switch could be turned on for an account with no payment method. Turning it on unlocks the paid analytics: market intelligence, scenario labs, the screener, reports and more. On a fresh free account, a paid-only endpoint went from 403 to 200.

F5 · Medium

One product could charge the other's prices

Fishing Rod Starter ($49) and Aegis Starter ($399) share the plan key starter, and the Stripe price lookup didn't know which product was asking. Once Stripe was configured, a Fishing Rod customer could have been sent to checkout at the Aegis price, or the other way round.

Fixes

What changed

#FixVerified live
F1, F2In production, signup always generates a new tenant and applies the free default plan. Body fields for either are ignored.A crafted signup asking for the operator tenant and enterprise got a fresh tenant on the free plan
F3In production, checkout without Stripe refuses with a reason and changes nothing. Dev and test keep the simulation.Returns "Online checkout isn't enabled yet"
F4Turning PAYG on requires a Stripe customer (402 + reason otherwise). Turning it off always works, and the account page shows the reason.PAYG on → 402; paid endpoint stays 403; PAYG off → 200
F5Fishing Rod reads its own price variables (STRIPE_PRICE_FR_<PLAN>), and starter never falls back to the shared one.Unit-tested; takes effect when Stripe goes live

Every fix came with regression tests, and the full suite passed before each deploy. The throwaway accounts used for live verification were deleted afterwards. Their audit-log entries were deliberately kept: the log is hash-chained, and deleting from it would break the chain that makes it trustworthy.

Exposure

Were they used?

No. Production holds only internal accounts, none created since early August, and no account had pay-as-you-go turned on. The holes were found before the launch traffic that would have found them.

◆ Lesson

Read every endpoint as if the UI didn't exist. All five findings were invisible from the screens: each needed a hand-written request or a missing config value. The browser is a convenience for honest users. The API is the real attack surface, and anything it accepts is an input, whether or not a form ever sends it.