| title |
aliases |
tags |
sources |
created |
updated |
| Multi-Tenant Fail-Open Authorization Bug |
| fail-open-authz |
| multitenant-authz-bug |
| get-accessible-projects-none |
|
| security |
| authorization |
| multitenant |
| fastapi |
| python |
| bug |
|
|
2026-04-29 |
2026-04-29 |
Multi-Tenant Fail-Open Authorization Bug
In multi-tenant systems, a function that retrieves accessible resource IDs for a user must return an empty list [] (deny-all) when the user has no org assignments — never None. Returning None is typically interpreted downstream as "no filter applied" (fail-open), giving the user unrestricted access to all tenants' data. This is a critical security vulnerability pattern that is easy to introduce and hard to detect through normal testing.
Key Points
get_accessible_project_ids() returning None means "no restriction" → fail-open (every project visible)
get_accessible_project_ids() returning [] means "deny all" → fail-closed (correct behavior)
- Always use
[] as the default return when a user has no assignments, never None or omitting a return statement
- Use 404 Not Found (not 403 Forbidden) for cross-tenant resource access — returning 403 confirms the resource exists, which is an information disclosure vulnerability
- Add a mandatory guard at the access-check boundary:
if project_ids is None: raise ValueError("BUG: authz returned None")
Details
The Vulnerable Pattern
# ❌ DANGEROUS — returns None when no assignments exist
async def get_accessible_project_ids(user_id: str, db) -> list[str] | None:
assignments = await db.org_assignments.find({"user_id": user_id}).to_list(None)
if not assignments:
return None # ← Bug: caller treats None as "no filter"
return [a["project_id"] for a in assignments]
# ❌ Caller fails open
async def list_projects(user_id: str, db):
project_ids = await get_accessible_project_ids(user_id, db)
query = {}
if project_ids is not None: # ← None passes this check!
query["_id"] = {"$in": project_ids}
return await db.projects.find(query).to_list(None) # Returns ALL projects
The Correct Pattern
# ✅ SAFE — always returns a list, empty list = deny all
async def get_accessible_project_ids(user_id: str, db) -> list[str]:
assignments = await db.org_assignments.find({"user_id": user_id}).to_list(None)
if not assignments:
return [] # ← Explicit deny-all
return [a["project_id"] for a in assignments]
# ✅ Caller fails closed
async def list_projects(user_id: str, db):
project_ids = await get_accessible_project_ids(user_id, db)
# Empty list → $in: [] → MongoDB returns zero documents ✓
return await db.projects.find({"_id": {"$in": project_ids}}).to_list(None)
Cross-Tenant Access: 404 Not 403
# ❌ Leaks existence of the resource
async def get_project(project_id: str, user_id: str, db):
project = await db.projects.find_one({"_id": project_id})
if not project:
raise HTTPException(404)
if project["tenant_id"] != user_tenant(user_id):
raise HTTPException(403, "Forbidden") # ← Tells attacker the project exists!
# ✅ 404 for all cross-tenant access — no existence disclosure
async def get_project(project_id: str, user_id: str, db):
project = await db.projects.find_one({
"_id": project_id,
"tenant_id": user_tenant(user_id) # ← Filter by tenant in the query itself
})
if not project:
raise HTTPException(404) # Same response whether not found OR wrong tenant
Defensive Guard at Boundary
# Add a guard wherever authz results are consumed
project_ids = await get_accessible_project_ids(user_id, db)
assert project_ids is not None, "BUG: authz function returned None — fail-open risk"
Related Concepts
Sources