A worked example of the document delivered at the end of an engagement, so you can judge the deliverable before you buy it.
Three issues let one customer of the platform reach another customer's data or money. Two of them need nothing more than an ordinary account and a changed number in a request. None of them would be caught by a scanner, because in every case the application behaved exactly as written — the code just never asked whether the caller was entitled to what they asked for.
The pattern underneath all three is the same, and it is the one worth taking away: the authorization check exists in one layer and is missing in another. It is enforced in the interface but not the API; on the route that creates a record but not the one that updates it; in the code that draws the permission toggle but not the handler that answers the request.
| Severity | Count | What it means for you |
|---|---|---|
| P1 Critical | 1 | Cross-tenant financial impact. Fix before the next release. |
| P2 High | 2 | Account takeover or privilege escalation within a tenant. |
| P3 Medium | 3 | Needs a precondition, or the impact is bounded. |
| Informational | 4 | Hardening. No exploitation path found during this engagement. |
Coverage is stated as a fraction, not implied. Anything I did not reach is listed as untested rather than quietly omitted, because untested is unknown — it is not the same as clean.
The 12 untested endpoints are the bulk import family under the reporting service. They require a data-warehouse credential that was not available in staging. They are named individually in Appendix A so the next engagement can resume there without rediscovering the gap.
The 4 blocked endpoints returned 503 for the whole test
window. I raised this on day 1 and again on day 3; the service was not restored before
testing closed.
iOS was not tested. The build was not provided. This is not a finding of "no issues on iOS" — it is an absence of evidence either way.
The invoice creation endpoint takes the owning organisation from an
org_id field in the request body. The server validates that the field is a
well-formed identifier and that the invoice total is positive. It never checks that the
caller is a member of the organisation named.
An attacker with a free trial account can issue invoices in the name of any customer on the platform, to that customer's own clients, with attacker-controlled bank details in the payment block. The invoice renders with the victim's branding and passes the platform's own outbound email authentication. Reversing it requires the victim to notice, and the money has already moved.
# 1. Authenticate as an ordinary member of tenant A (a free trial is enough) POST /api/v2/auth/login {"email":"attacker@example-a.test","password":"..."} --> 200 OK, session cookie for tenant A # 2. Create an invoice, but name tenant B as the owner POST /api/v2/invoices Cookie: session=<tenant A session> { "org_id": "org_b8f21c04", // tenant B, not ours "customer_id": "cus_44e1", "amount_cents": 480000, "payment_details": {"iban": "<attacker controlled>"} } --> 201 Created {"invoice_id":"inv_9c22a1","org_id":"org_b8f21c04"} # 3. Confirm it is real, and owned by tenant B GET /api/v2/invoices/inv_9c22a1 --> 200 OK status "issued", org_b8f21c04, attacker IBAN intact
Reproduced 5 times across 3 provisioned tenant pairs, in both directions. It is not a race and does not depend on ordering.
Every request is well-formed and every response is a success. There is no payload,
no injection, no error. The only thing wrong is who was allowed to make the
request, and that requires knowing that org_b8f21c04 belongs to somebody
else.
org_id from the session, not the request body. If the field
must stay for API compatibility, reject any request where it disagrees with the
session's organisation.403 when naming tenant B — on create and on update. The
update route shares this handler and inherits the same flaw.Fixed and verified. org_id is now ignored on input. I re-ran the
original reproduction and three variants, including the update route: all return
403.
Permissions are resolved once, when the session is created, and cached in the session record. Removing a permission updates the role but does not invalidate sessions already holding it. The interface reflects the change immediately, which makes the problem invisible to an administrator checking their work.
Offboarding does not take effect. A departing employee, or a contractor whose access was narrowed, keeps the removed capability for up to the session lifetime — here, 30 days with sliding renewal, so in practice indefinitely for anyone still using the product.
# 1. As admin, remove "invoices:delete" from the Member role PATCH /api/v2/roles/role_member {"remove_permissions":["invoices:delete"]} --> 200 OK # 2. Confirm the platform agrees it is gone GET /api/v2/roles/role_member --> 200 OK, "invoices:delete" absent from the array the admin console also stops rendering the control # 3. In the member's existing session, do it anyway DELETE /api/v2/invoices/inv_1f0093 Cookie: session=<member session issued before step 1> --> 204 No Content invoice deleted
Reproduced on 7 separate permissions. The organisation-level master switch for the same capability behaves identically — it disables the control in the interface and nothing else.
Fixed and verified. Roles now carry a version; sessions holding an older version are rejected on the next request. Re-tested all 7 permissions and the master switch.
The export status endpoint is correctly authorized — you cannot fetch another tenant's export. But the error body for a job you do not own includes the requested filename, which is derived from the owning organisation's legal name and the report period.
Bounded, and rated accordingly. Enumerating sequential job identifiers yields a list of customer legal names and their reporting cadence. No document contents are exposed. This is a competitor-intelligence and enumeration issue, not a data breach, and I have not inflated it into one.
GET /api/v2/exports/job_10041/status Cookie: session=<tenant A session> --> 403 Forbidden { "error": "not_authorized", "detail": "job 'northwind-holdings-q3-2026.xlsx' belongs to another organisation" }
404 rather than 403 for objects the caller
cannot see, and omit the identifier entirely from the body.Fixed and verified.
The full report contains all 10 findings. Three are shown here: one at each severity band, chosen to show what the writing looks like when the impact is serious, when it is structural, and when it is genuinely minor. The bounded one matters as much as the critical one — it shows you what I do when a finding is not a big deal.
Recorded because a negative is only worth something if you know it was actually tested, and by a method that could have demonstrated the positive case.