Replaying an expired trial and racing plan limits
Licenses, plan settings and per-account limits enforced by the UI, or by checks that don't hold under concurrency. Requests replayed from an expired trial worked module by module, and simultaneous requests went past a member cap and a one-workspace rule.
This product sells paid modules on top of a free trial, so it is full of rules. This module is for paying customers. This plan allows a fixed number of members. One account gets one workspace. Business logic bugs are those rules not being enforced where they need to be: the code does what it was written to do, but the rule was only ever written into the front end, or checked in a way that falls apart when requests arrive together.
The way I test this: I keep two accounts, one that is allowed to do something and one that isn’t, such as an expired trial, a lower role or a free plan. I capture a request from the allowed account and replay it with the other account’s token. If it works, the rule was never on the server.
Expired trial replay
Each module is unlocked by a license or a 30-day trial. When my trial ended the modules went dark in the UI, but the UI is only a client and the real work happens in API calls.
With a fresh account whose trial was still running, I used a module and captured the request. Then I took the expired account’s token and one of its own project IDs and put them into the same request:
POST /api/projects/<expired-account-project> /reports HTTP/1.1
Authorization: Bearer <expired-account-token>
Content-Type: application/json
{"title": "Issue detail", "format": "pdf"}201 Created. The backend never asked whether that account still had a license.
I went through the other modules the same way and found the same bug in each. Later I found the same gap in a GraphQL API, where premium operations didn’t check the entitlement at all.
I don’t ask whether I can do some particular thing. I ask whether the gate is in the UI or on the server, and replaying a good request with an account that shouldn’t be allowed answers that straight away.
Disabled toggle
A workspace admin screen has a setting for a premium feature. On a free workspace the toggle is disabled, with a note saying it needs a paid plan.
Settings are saved by sending the whole preferences object. I changed an unrelated option, external sharing, intercepted the save and found the premium flag in the body. I changed false to true and sent it:
{"externalSharing": true, "advancedMode": "true" }The feature turned on. The UI had hidden the control, and the endpoint saved the field without checking the plan. Save requests that carry a whole settings object are worth reading line by line for this reason. The UI decides which fields you can touch, and the endpoint often doesn’t.
Member cap race
A free workspace has a member limit. The check existed, but it didn’t hold when I sent several requests to add members at the same moment.
This is the classic check-then-act race. A handler reads the current count, compares it with the limit and then inserts. If several requests read the count before any of them has inserted, they all pass the check:
const count = await members.count(workspaceId);
if (count >= plan.memberLimit) throw new LimitReached();
await members.insert(workspaceId, invitee); // the other requests passed the same checkWorkspace limit race
Each account is supposed to own a single workspace. Sending several create-workspace requests at the same time left one account owning several. The odd part is that they didn’t appear straight away. They became accessible about two hours later.
I couldn’t explain the delay. It happened twice, and I said exactly that in the report instead of pretending I understood it.
Fix
Entitlements have to be checked on the server, on every API call that does paid work. Hiding a module or disabling a toggle only changes what the UI shows.
Limits need to be enforced atomically, so concurrent requests can’t all pass the same check. For a strict one-per-account rule a unique index does it. For counters, serialize the writes for each workspace:
CREATE UNIQUE INDEX one_workspace_per_owner ON workspaces (owner_id);BEGIN;
SELECT id FROM workspaces WHERE id = $1 FOR UPDATE; -- concurrent adds wait here
SELECT count(*) FROM members WHERE workspace_id = $1;
-- insert only if the count is still below the plan limit
COMMIT;Disclosure
All of this was reported privately to the vendor. The vendor isn’t named here, and paths, field names and IDs are changed. Each request has the same structure as the original.