Summary
Several authenticated API resources trusted client-controlled identifiers and domain parameters. Changing those values exposed records belonging to other users and organizational domains.
Technical workflow
The authorization matrix used synthetic records and two approved test identities. A single-object request was enough to validate the server decision; no bulk enumeration was required:
curl --fail-with-body --max-time 5 \
-H 'Authorization: Bearer TEST_TOKEN_A' \
'https://AUTHORIZED_HOST/api/records/TEST_RECORD_B?domain=TEST_DOMAIN_B'
The expected response for an object outside the caller’s scope is a uniform 403 or 404. A minimal policy boundary looks like this:
def can_read(principal, record) -> bool:
return (
principal.is_authenticated
and principal.tenant_id == record.tenant_id
and record.id in principal.visible_record_ids
)
def require_read(principal, record) -> None:
if not can_read(principal, record):
raise PermissionError("record is not visible to this principal")
The same decision must protect list filters, individual reads, exports and downloads. Pagination and rate limiting reduce blast radius but do not repair broken object authorization.
Impact
The same authorization gap affected listings, individual records and document metadata. A low-privilege account could therefore move from a single-object test to large-scale data exposure.
Defensive lessons
- Authorize the requested object against the authenticated principal on the server.
- Derive tenant and domain scope from the session, not request parameters.
- Apply the same policy to listings, downloads, writes and deletes.
- Add cross-tenant authorization tests to every API release.
Retest evidence should include response status, response shape, audit event and the absence of sensitive fields for every denied combination.
The affected organization and exact identifiers are withheld.