← Back to waltwhitehat.com

Application security assessment

A worked example of the document delivered at the end of an engagement, so you can judge the deliverable before you buy it.

Client
Example Co. — B2B invoicing platform, multi-tenant SaaS
Engagement
Standard engagement, 5 testing days
Environment
Staging, seeded data, all roles provisioned
Authorization
Signed scope and test window on file before any traffic
Tester
Mostafa Mamdoh, sole tester
Report issued
3 working days after testing closed
Retest
Included, one round, on request once fixes land

01Summary for the people paying for this

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.

SeverityCountWhat it means for you
P1 Critical1 Cross-tenant financial impact. Fix before the next release.
P2 High2 Account takeover or privilege escalation within a tenant.
P3 Medium3 Needs a precondition, or the impact is bounded.
Informational4 Hardening. No exploitation path found during this engagement.
The P1 was reported to your engineering channel on day 2, four hours after I confirmed it, with a working reproduction. You did not wait for this document to start fixing it.

02What was tested, and what was not

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.

API endpoints: 147 tested / 12 untested / 4 blocked
Roles exercised: 6 of 6 — owner, admin, member, billing, read-only, external collaborator
Tenant pairs: 3 of 3 provisioned pairs, both directions
Mobile client: 1 of 2 — Android reviewed, iOS not supplied

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.

03Findings

P1 Critical

Any tenant can raise and settle invoices against another tenant

EX-2026-001

Summary

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.

Impact

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.

Reproduction

# 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.

Why a scanner does not find this

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.

Remediation

  • Derive 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.
  • Apply the same rule at the data layer: scope the invoice repository query by the caller's organisation so a handler that forgets the check still cannot cross the boundary.
  • Add a regression test that asserts a member of tenant A receives 403 when naming tenant B — on create and on update. The update route shares this handler and inherits the same flaw.

Retest outcome

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.

P2 High

Revoked permissions keep working until the session expires

EX-2026-002

Summary

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.

Impact

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.

Reproduction

# 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.

Remediation

  • Resolve permissions per request from the role, or version the role and reject sessions carrying a stale version.
  • If per-request resolution is too costly, invalidate affected sessions on any role change. Partial credit only: it fixes the symptom, not the pattern.
  • Treat the interface as a rendering concern. It should never be the only place a permission is enforced, and it should never be the thing an administrator trusts to confirm that a revocation worked.

Retest outcome

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.

P3 Medium

Export job status discloses other tenants' filenames

EX-2026-004

Summary

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.

Impact

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.

Reproduction

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"
}

Remediation

  • Return 404 rather than 403 for objects the caller cannot see, and omit the identifier entirely from the body.
  • Keep the detail server-side in logs, where it is useful for support without being readable by a stranger.

Retest outcome

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.

04What I did not find

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.

05What happens next

  1. You fix what you decide is worth fixing. Not every finding needs action, and the report says which ones I would leave.
  2. You tell me fixes are in. One retest round is included; further rounds are quoted separately.
  3. I re-run the original reproduction and reasonable variants — not just the exact request, because a patch that only blocks the literal payload is a finding I will report again.
  4. You get a retest letter recording what was verified fixed, what was not, and what was newly introduced by the fixes. It is written to be handed to a customer or an auditor.