6 confirmed cross-tenant leaksData 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
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.
`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.
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.
`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.
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.
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.
`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
✓self-access:A — tenant A read its own object via GET /api/documents/{document_id}
✓self-access:B — tenant B read its own object via GET /api/documents/{document_id}
✓self-access:A:closing — tenant A read its own object via GET /api/documents/{document_id}
✓self-access:B:closing — tenant B read its own object via GET /api/documents/{document_id}
What was checked and held
14 cross-tenant attempts were correctly refused.
attack
refused
idor
2
listing
6
mass_assign
6
14 attempts were inconclusive — the oracle could not decide, which is not the same as enforcement.
GET /api/invoices (param_override) — every one of 1 query-parameter spellings was refused; the endpoint does not honour a client-supplied tenant
GET /api/stats (param_override) — every one of 1 query-parameter spellings was refused; the endpoint does not honour a client-supplied tenant
GET /api/invoices (param_override) — every one of 1 query-parameter spellings was refused; the endpoint does not honour a client-supplied tenant
GET /api/stats (param_override) — every one of 1 query-parameter spellings was refused; the endpoint does not honour a client-supplied tenant
GET /api/customers (aggregate) — no count-shaped field in the response, so there was no exact number to compare against the seeded rows
GET /api/documents (aggregate) — no count-shaped field in the response, so there was no exact number to compare against the seeded rows
GET /api/invoices (aggregate) — no count-shaped field in the response, so there was no exact number to compare against the seeded rows
GET /api/documents/{document_id} (aggregate) — no count-shaped field in the response, so there was no exact number to compare against the seeded rows
GET /api/invoices/{invoice_id} (aggregate) — no count-shaped field in the response, so there was no exact number to compare against the seeded rows
GET /api/customers (aggregate) — no count-shaped field in the response, so there was no exact number to compare against the seeded rows
Run notes
1 endpoint skipped by [probe] exclude_paths
Standards
Every finding above, indexed by the control it maps to.
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.