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.
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
| # | Fix | Verified live |
|---|---|---|
| F1, F2 | In 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 |
| F3 | In production, checkout without Stripe refuses with a reason and changes nothing. Dev and test keep the simulation. | Returns "Online checkout isn't enabled yet" |
| F4 | Turning 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 |
| F5 | Fishing 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.

