TenantTrace — tenant isolation audit

run VALID · http://127.0.0.1:8001

6 confirmed cross-tenant leaks Data belonging to another tenant was read or written back to 2 authenticated tenants. Each finding below carries the request that proved it.

Scope

Target
http://127.0.0.1:8001
Started
2026-07-27T14:21:16+00:00
Duration
0.5s
Tool
tenanttrace 0.1.0
Surface probed
10 endpoints of 10 reachable · 11 operations declared, the rest outside this run's shape
Attacks run
idor, listing, param_override, cache, aggregate, mass_assign
Evidence
canaries, identifiers, and exact counts

Summary

6confirmedproven by seeded ground truth
0suspectedhypotheses; never gate CI
10/11endpointsprobed of those known
14refusedattempts the app blocked
severityconfirmedsuspectedinconclusive
critical400
high200

44 cross-tenant attempts across 10 endpoints.

Access graph

Every line is an access this run proved. 14 attempts were refused and 14 attempts could not be judged; neither is drawn, so a short graph is not the same as a thorough audit.

tenant A↔B → GET /api/invoices/{invoice_id} (critical) tenant A↔B → GET /api/documents (critical) tenant A↔B → GET /api/customers (critical) tenant A↔B → GET /api/documents/{document_id} (high) tenant A↔B → GET /api/stats (high) tenant A↔B → POST /api/invoices (critical) tenant A↔B GET /api/customers GET /api/documents GET /api/documents/{document_id} GET /api/invoices/{invoice_id} GET /api/stats POST /api/invoices
criticalhigh↔ proven in both directions

Findings

idseverityconfidencewhere
TT-0001criticalconfirmedGET /api/customers
TT-0002criticalconfirmedGET /api/documents
TT-0003criticalconfirmedGET /api/invoices/{invoice_id}
TT-0004criticalconfirmedPOST /api/invoices
TT-0005highconfirmedGET /api/documents/{document_id}
TT-0006highconfirmedGET /api/stats
criticalconfirmedprobeCWE-639CWE-807OWASP-API1:2023ASVS-V4.2.1

TT-0001 · Client-supplied tenant honoured at GET /api/customers

GET /api/customers

Request
GET http://127.0.0.1:8001/api/customers?tenant_id=9a8f7557-6de7-4cbd-8fc8-15eece3b82a7
Response
200
Canary that proved it
tt-canary-…ac95f950
Leaked identifiers
d98876cc-2d4b-4f03-9d45-cef8c2a22c85, fcd2396a-de3b-4c9a-87fc-ac5b52f49fcb
Detail
response served to tenant A contains tenant B's seeded canary (via query parameter 'tenant_id') [reproduced 2×]
Response body
[{"id":"fcd2396a-de3b-4c9a-87fc-ac5b52f49fcb","name":"tt-canary-…ac95f950 customer 0","email":"c0@example.invalid","tenant_id":"9a8f7557-6de7-4cbd-8fc8-15eece3b82a7"},{"id":"d98876cc-2d4b-4f03-9d45-cef8c2a22c85","name":"tt-canary-…ac95f950 customer 1","email":"c1@example.invalid","tenant_id":"9a8f7557-6de7-4cbd-8fc8-15eece3b82a7"}]
Remediation

`GET /api/customers` takes the tenant from the request instead of from the authenticated session. Changing one parameter switches tenants, which makes every other isolation control on this endpoint irrelevant.

The tenant must be derived from the credential and from nowhere else:

# before — the caller chooses
def list_items(tenant_id: str | None = None, ctx = Depends(current)):
    scope = tenant_id or ctx.tenant_id

# after — the credential chooses
def list_items(ctx = Depends(current)):
    scope = ctx.tenant_id

If an internal caller genuinely needs to select a tenant, that is a separate, explicitly authorised admin endpoint — and it belongs in `cross_tenant_allowlist`, not in the tenant-facing route.

criticalconfirmedprobeCWE-200OWASP-API1:2023ASVS-V4.2.1

TT-0002 · Collection at GET /api/documents returns other tenants' rows

GET /api/documents

Request
GET http://127.0.0.1:8001/api/documents
Response
200
Canary that proved it
tt-canary-…ac95f950
Leaked identifiers
dc11212c-281f-4eb7-91c9-8c8d36be82b7, dc2b8a4d-e4aa-4e45-8c4e-dece5aa50e41
Detail
response served to tenant A contains tenant B's seeded canary [reproduced 2×]
Response body
[{"id":"505ead99-cc85-497c-996e-02e55921f7db","title":"Document 0","body":"tt-canary-…c2847275 document body 0","tenant_id":"c32b4cdc-01fc-4b7d-91c2-66dac3173cc7"},{"id":"abcda6fb-b0e9-4d46-8c5e-0ef7e7ccc2db","title":"Document 1","body":"tt-canary-…c2847275 document body 1","tenant_id":"c32b4cdc-01fc-4b7d-91c2-66dac3173cc7"},{"id":"dc2b8a4d-e4aa-4e45-8c4e-dece5aa50e41","title":"Document 0","body":"tt-canary-…ac95f950 document body 0","tenant_id":"9a8f7557-6de7-4cbd-8fc… [189 more characters]
Remediation

`GET /api/documents` lists rows belonging to every tenant. A collection query without a tenant predicate leaks in bulk, which is strictly worse than a single-object leak: the caller does not even need to guess an id.

# before
rows = session.scalars(select(Document)).all()

# after
rows = session.scalars(
    select(Document).where(Document.tenant_id == ctx.tenant_id)
).all()

Add a regression test that seeds two tenants and asserts the list length for one of them — a single-tenant test suite cannot catch this class of bug.

criticalconfirmedprobeCWE-639OWASP-API1:2023ASVS-V4.2.1

TT-0003 · Cross-tenant read on GET /api/invoices/{invoice_id}

GET /api/invoices/{invoice_id}

Request
GET http://127.0.0.1:8001/api/invoices/7e17cdaa-349e-4bd1-b08a-b34f655c8bb1
Response
200
Canary that proved it
tt-canary-…ac95f950
Detail
response served to tenant A contains tenant B's seeded canary [reproduced 2×]
Response body
{"id":"7e17cdaa-349e-4bd1-b08a-b34f655c8bb1","title":"tt-canary-…ac95f950 invoice 0","amount":100,"tenant_id":"9a8f7557-6de7-4cbd-8fc8-15eece3b82a7","created_at":"2026-07-27T14:21:16.972898"}
Remediation

`GET /api/invoices/{invoice_id}` returns an object owned by another tenant. The lookup resolves the identifier without constraining it to the caller's tenant, so any tenant holding a valid id can read the record.

Fix it at the data-access boundary, not in the route handler:

# before — the id is trusted on its own
obj = session.get(Invoice, obj_id)

# after — identity is (tenant, id), never id alone
obj = session.scalars(
    select(Invoice)
    .where(Invoice.id == obj_id)
    .where(Invoice.tenant_id == ctx.tenant_id)
).one_or_none()

Better still, make the unsafe call unavailable: put the tenant predicate in a repository method or a SQLAlchemy `with_loader_criteria` global scope so a future route cannot forget it. Return 404 rather than 403 for another tenant's object so the response does not confirm that the id exists.

criticalconfirmedprobeCWE-639CWE-915OWASP-API3:2023ASVS-V4.2.1

TT-0004 · Cross-tenant write via POST /api/invoices

POST /api/invoices

Request
POST http://127.0.0.1:8001/api/invoices
Request body
{"title": "tt-canary-…c2847275 (created by TenantTrace, safe to delete)", "amount": 1, "tenant_id": "9a8f7557-6de7-4cbd-8fc8-15eece3b82a7"}
Response
201
Leaked identifiers
9a8f7557-6de7-4cbd-8fc8-15eece3b82a7
Detail
record created by tenant A is owned by tenant B (tenant_id=9a8f7557-6de7-4cbd-8fc8-15eece3b82a7) (created record f0fe6e5c-21c7-4707-82d7-95b4b52f5d3b was deleted) [reproduced 2×]
Response body
{"id":"f0fe6e5c-21c7-4707-82d7-95b4b52f5d3b","title":"tt-canary-…c2847275 (created by TenantTrace, safe to delete)","amount":1,"tenant_id":"9a8f7557-6de7-4cbd-8fc8-15eece3b82a7","created_at":"2026-07-27T14:21:17.366120Z"}
Remediation

`POST /api/invoices` accepts a client-supplied `tenant_id` and writes the record into another tenant. Mass assignment binds the whole request body onto the model, so any column the model exposes is attacker-controlled.

Bind an explicit input schema that simply does not contain the ownership column, and set it from the authenticated context:

class $modelCreate(BaseModel):
    model_config = ConfigDict(extra='forbid')  # reject unknown keys
    title: str
    amount: int
    # NOTE: no tenant_id here — ownership is never client input

obj = Invoice(**payload.model_dump(), tenant_id=ctx.tenant_id)

Apply the same rule to updates: an update must never be able to move a record between tenants.

highconfirmedprobeCWE-524OWASP-API1:2023ASVS-V8.1.1

TT-0005 · Tenant-less cache key serves another tenant at GET /api/documents/{document_id}

GET /api/documents/{document_id}

Request
GET http://127.0.0.1:8001/api/documents/dc2b8a4d-e4aa-4e45-8c4e-dece5aa50e41
Response
200
Canary that proved it
tt-canary-…ac95f950
Detail
response served to tenant A contains tenant B's seeded canary — the same request returned 404 on a cold cache, so the response came from a cache entry keyed without the tenant [reproduced 2×]
Response body
{"id":"dc2b8a4d-e4aa-4e45-8c4e-dece5aa50e41","title":"Document 0","body":"tt-canary-…ac95f950 document body 0","tenant_id":"9a8f7557-6de7-4cbd-8fc8-15eece3b82a7"}
Remediation

`GET /api/documents/{document_id}` queries correctly but caches the result under a key that omits the tenant. Whoever populates the entry first wins, so the leak is intermittent and load-dependent — the worst kind to reproduce, and invisible to a correct-looking query.

# before
key = f"invoice:{obj_id}"

# after — ownership is part of identity, in the cache too
key = f"invoice:{ctx.tenant_id}:{obj_id}"

Centralise key construction in one helper that takes the tenant as a required argument, so a caller cannot omit it. The same rule applies to background-job payloads, rate-limit buckets, and memoised lookups.

highconfirmedprobeCWE-200OWASP-API1:2023ASVS-V4.2.1

TT-0006 · Aggregate at GET /api/stats counts other tenants' rows

GET /api/stats

Request
GET http://127.0.0.1:8001/api/stats
Response
200
Expected
3
Observed
6
Detail
invoice_count is 6 for tenant A, which owns 3 — the aggregate is computed over other tenants' rows [reproduced 6×]
Response body
{"invoice_count":6,"document_count":4,"customer_count":4,"invoice_total":606}
Remediation

`GET /api/stats` computes its aggregate over the whole table. No row content crosses the boundary, but counts and sums disclose another tenant's volume — and this is usually the same missing predicate that will leak rows on the next endpoint.

# before
total = session.scalar(select(func.count()).select_from(Stat))

# after
total = session.scalar(
    select(func.count())
    .select_from(Stat)
    .where(Stat.tenant_id == ctx.tenant_id)
)

Aggregates are routinely written outside the repository layer, so grep for `func.count`, `func.sum`, and raw `COUNT(` after fixing this one.

Run integrity

Why the answer above can be trusted: the checks that prove the harness worked, and what the application refused.

Positive controls

What was checked and held

14 cross-tenant attempts were correctly refused.

attackrefused
idor2
listing6
mass_assign6

14 attempts were inconclusive — the oracle could not decide, which is not the same as enforcement.

Run notes

Standards

Every finding above, indexed by the control it maps to.

referenceworstfindingsids
OWASP-API1:2023critical5TT-0001, TT-0002, TT-0003, TT-0005, TT-0006
OWASP-API3:2023critical1TT-0004
CWE-200critical2TT-0002, TT-0006
CWE-524high1TT-0005
CWE-639critical3TT-0001, TT-0003, TT-0004
CWE-807critical1TT-0001
CWE-915critical1TT-0004
ASVS-V4.2.1critical5TT-0001, TT-0002, TT-0003, TT-0004, TT-0006
ASVS-V8.1.1high1TT-0005
How to read this report
Confirmed
A canary planted in another tenant's data came back in this tenant's response, or an exact count did not match. Proven, not inferred — these are the only findings that fail CI by default.
Suspected
A hypothesis from reading the source: a query that looks unscoped. It has not been reproduced over HTTP and never gates a build on its own.
Positive control
A tenant reading its own data. If that fails, the harness is broken and an empty finding list means nothing — so the run is marked INVALID rather than clean.
Severity
The inherent severity of the category, never discounted by how sure we are. A suspected critical is still a critical that we are unsure about; flattening the two would let a hypothesis disappear below a CI threshold.
Inconclusive
The attempt ran but the oracle could not decide — a truncated body, a redirect. Deliberately not counted as enforcement.