Build one queryset that holds the rule, from request.user and its tenant and role, never from request data: Invoice.objects.filter(tenant=member.tenant), narrowed to owner=user for members. Start every detail, list and export query from it, or pass it to get_object_or_404(). Return 404 otherwise; refuse an export when any requested ID is out of scope.
- List every view, serializer, admin and background task that reads an invoice: detail, list, search, export and download. Note where each gets the IDs and the user.
- Take the user only from request.user (AuthenticationMiddleware sets it from the session), and its tenant and role from your own tables. Delete code that reads a user, tenant or role from request.GET, request.POST, JSON bodies or headers.
- Write one function that returns the queryset the user may read (here visible_invoices(user)) and start every query from it: .filter(pk=...), .order_by(...), .filter(pk__in=ids).
- For a single object use get_object_or_404(visible_invoices(request.user), pk=pk); it accepts a queryset, so missing and not yours both become 404.
- For bulk endpoints, compare the rows the scoped query returned with the IDs that were asked for, and refuse the whole request if any are missing.
- Add regression tests that sign in (client.force_login) as a colleague, as a member of another tenant and as a billing admin of another tenant, and replay invoice IDs against the detail, list and export routes.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
def get_invoice(user, invoice_id: int) -> dict | None:
found = rows(Invoice.objects.filter(pk=invoice_id))
return found[0] if found else None
def list_invoices(user) -> list[dict]:
return rows(Invoice.objects.filter(tenant=user.member.tenant).order_by("id"))
def export_invoices(user, ids: list[int]) -> list[dict]:
return rows(Invoice.objects.filter(pk__in=ids).order_by("id"))With IMPL=vulnerable, 6 of 15 tests fail (6 exploit tests).
The fix
def visible_invoices(user):
"""Members read their own invoices; billing admins read every invoice in their own tenant.
The tenant filter applies to everyone, admins included."""
member = user.member
invoices = Invoice.objects.filter(tenant=member.tenant)
if member.role != "billing_admin":
invoices = invoices.filter(owner=user)
return invoices
def get_invoice(user, invoice_id: int) -> dict | None:
found = rows(visible_invoices(user).filter(pk=invoice_id))
return found[0] if found else None
def list_invoices(user) -> list[dict]:
return rows(visible_invoices(user).order_by("id"))
def export_invoices(user, ids: list[int]) -> list[dict]:
return rows(visible_invoices(user).filter(pk__in=ids).order_by("id"))With IMPL=fixed, 15 of 15 tests pass.
A fix that looks safe but is not
def _can_read(user, invoice: dict) -> bool:
return invoice["owner_id"] == user.username or user.member.role == "billing_admin"
def get_invoice(user, invoice_id: int) -> dict | None:
found = rows(Invoice.objects.filter(pk=invoice_id))
return found[0] if found and _can_read(user, found[0]) else None
def list_invoices(user) -> list[dict]:
return [invoice for invoice in rows(Invoice.objects.order_by("id")) if _can_read(user, invoice)]
def export_invoices(user, ids: list[int]) -> list[dict]:
invoices = rows(Invoice.objects.filter(pk__in=ids).order_by("id"))
return [invoice for invoice in invoices if _can_read(user, invoice)]With IMPL=wrong, 3 of 15 tests fail (3 exploit tests).
Routes: the user comes from the session, not the request
def signed_in(view):
"""AuthenticationMiddleware sets request.user from the session; the user's tenant and role
come from the Member table, never from the request. An API answers 401, not a redirect."""
@wraps(view)
def wrapped(request, *args, **kwargs):
if not request.user.is_authenticated:
return JsonResponse({"error": "sign in first"}, status=401)
return view(request, *args, **kwargs)
return wrapped
@require_GET
@signed_in
def list_invoices(request):
return JsonResponse(load_impl().list_invoices(request.user), safe=False)
@require_GET
@signed_in
def get_invoice(request, invoice_id: int):
invoice = load_impl().get_invoice(request.user, invoice_id) if invoice_id <= MAX_ID else None
# Missing and not yours get the same answer, so IDs cannot be probed.
return JsonResponse(invoice) if invoice is not None else not_found()
@require_GET
@signed_in
def export_invoices(request):
ids = parse_ids(request.GET.get("ids"))
if ids is None:
return JsonResponse({"error": f"ids must be 1 to {MAX_EXPORT_IDS} invoice IDs, comma-separated"}, status=400)
invoices = load_impl().export_invoices(request.user, ids)
# All or nothing: one missing or unreadable ID refuses the whole export, so a partial file is
# never mistaken for the full one.
if len(invoices) != len(ids):
return not_found()
return HttpResponse(to_csv(invoices), content_type="text/csv; charset=utf-8")The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
# Exploit: other users, other tenants and other tenants' admins must get nothing.
def assert_hidden(signed_in, user: str, path: str, number: str) -> None:
"""Denied looks exactly like missing: 404 and none of the invoice's data."""
res = signed_in(user).get(path)
body = res.content.decode()
assert res.status_code == 404, f"expected 404, got {res.status_code}: {body}"
assert number not in body, f"response leaked {number}"
def test_exploit_member_cannot_read_a_colleagues_invoice_in_the_same_tenant(signed_in):
assert_hidden(signed_in, "alice", "/invoices/502", "INV-502")
def test_exploit_member_of_another_tenant_cannot_read_the_invoice(signed_in):
assert_hidden(signed_in, "erin", "/invoices/501", "INV-501")
def test_exploit_billing_admin_cannot_read_an_invoice_in_another_tenant(signed_in):
assert_hidden(signed_in, "cara", "/invoices/601", "INV-601")
def test_exploit_members_list_leaves_out_colleagues_invoices(signed_in):
assert listed_ids(signed_in, "alice") == [501, 503]
def test_exploit_billing_admins_list_stays_inside_their_tenant(signed_in):
assert listed_ids(signed_in, "cara") == [501, 502, 503]
def test_exploit_export_that_includes_a_colleagues_invoice_is_refused_whole(signed_in):
assert_hidden(signed_in, "alice", "/invoices/export?ids=501,502", "INV-502")
def test_exploit_billing_admins_export_into_another_tenant_is_refused_whole(signed_in):
assert_hidden(signed_in, "cara", "/invoices/export?ids=502,601", "INV-601")- invoice.owner == user or member.role == 'billing_admin' lets a billing admin of any tenant read every tenant's invoices. That is this card's wrong implementation, and every test inside one tenant passes against it.
- Invoice.objects.get(pk=pk) and get_object_or_404(Invoice, pk=pk) look up by primary key alone. Pass the scoped queryset instead of the model.
- @login_required only checks that someone is signed in; it says nothing about which objects they may read. For an API it also redirects to LOGIN_URL rather than answering 401.
- Django REST Framework applies has_object_permission() only when a view calls get_object(): list views never run it, so filter get_queryset() by the user as well.
- Filtering in Python after an unscoped query loads other tenants' data and breaks pagination and counts. Put the scope in the queryset.
- A tenant ID from a header, a subdomain or the URL is only a selector. If you accept one, check that request.user belongs to that tenant before using it.
- Random IDs such as uuid4() make guessing harder, but leaked links, logs and browser history still expose them; the check is still required.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs missing-authorization/python-django does this for you).
- Against your own API, sign in as two members of one tenant and as a billing admin of another tenant, then replay each other's invoice IDs on the detail, list and export routes. Every request that crosses a boundary must get a 404 or only in-scope rows.
- Do not expect a scanner to find this: Bandit has no authorization check, and Semgrep's Django, Python, security-audit and OWASP Top 10 packs reported nothing on the vulnerable code. The regression tests are the control.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | p/django, p/python, p/security-audit, p/owasp-top-ten | No findings on any implementation: the vulnerable, fixed and wrong querysets look the same to every rule in these packs.Checked: Semgrep 1.180.0 (CE); semgrep scan --config <pack> invoices config |
| Bandit | all plugins (default profile)Bandit 1.9.4 | No findings. Bandit has no authorization plugin, so its silence says nothing about object-level access.Checked: Bandit 1.9.4; bandit -r invoices config |
No rule targets object-level authorization in Django. The only Semgrep CWE-639 rule is Rails' check-unscoped-find, and in github/codeql main at b5b8165 the only Python query tagged CWE-285, CWE-639, CWE-862 or CWE-863 is PamAuthorization.ql (PAM account checks).
| Component | Version |
|---|---|
| Language | Python |
| Runtime | CPython 3.14.8 |
| Framework | Django 6.1.2 (ORM over SQLite, django.contrib.auth sessions) |
| django | 6.1.2 |
| pytest | 9.1.1 |
| pytest-django | 4.14.0 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the missing-authorization/python-djangofolder.
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt# Expected: every test passes
IMPL=fixed .venv/bin/python -m pytest
# Expected: an exploit test fails
IMPL=vulnerable .venv/bin/python -m pytest
# Expected: a test fails
IMPL=wrong .venv/bin/python -m pytestEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs missing-authorization/python-django runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- python CPython 3.14.8
- django 6.1.2
- pytest 9.1.1
- pytest-django 4.14.0
- semgrep 1.180.0
- bandit 1.9.4
- Matrix
- fixed: 15 of 15 tests pass; vulnerable: 6 of 15 tests fail (6 exploit tests); wrong: 3 of 15 tests fail (3 exploit tests)
- Test results
- fixed 15/15 pass; vulnerable 6/15 fail (6 exploit tests); wrong 3/15 fail (3 exploit tests), offline after install
- Dependency freshness
- Django 6.1.2 was published on 2026-10-06 and is taken at four days old because it is a security release (CVE-2026-77050, CVE-2026-84429, CVE-2026-87890); pytest-django 4.14.0 dates from 2026-08-10. iniconfig is pinned at 2.3.0 rather than 2.3.1 (2026-10-06), which was four days old and is not a security release. OSV reported no known vulnerabilities for any pin. Rerun on CPython 3.14.8, the 30 September 2026 expedited security release.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP API Security Top 10 2023: API1 Broken Object Level Authorization
- OWASP Insecure Direct Object Reference Prevention Cheat Sheet
- OWASP Multi-Tenant Security Cheat Sheet
- Django 6.1: get_object_or_404() (accepts a QuerySet)
- Django 6.1: Using the authentication system (request.user, login_required)
- Django 6.1 testing tools: Client.force_login()
- Django REST framework: limitations of object level permissions