CWE-863: Incorrect Authorization - Python
Overview
In Django REST Framework, Incorrect Authorization commonly appears as a permissions.BasePermission that implements has_permission (checked before the view runs, role-only) but not has_object_permission (checked against the specific instance), so any authenticated user with the right role can act on any object regardless of ownership. It also appears as a denylist role comparison that names the refused roles and admits the rest, failing open on a role value the check never anticipated, or a check duplicated inconsistently across function-based views instead of centralized in one permission class. Fix by implementing both permission methods and using an explicit role allowlist plus an ownership comparison in has_object_permission.
Common Vulnerable Patterns
has_permission Without has_object_permission
# VULNERABLE - role check passes for any authenticated user with the role,
# but nothing compares the specific object to its owner
from rest_framework import permissions, generics
class IsEditor(permissions.BasePermission):
def has_permission(self, request, view):
return request.user.role in {"admin", "editor"}
# has_object_permission is never overridden, so DRF's default
# implementation (return True) applies to every object.
class OrderDetailView(generics.RetrieveUpdateDestroyAPIView):
queryset = Order.objects.all()
serializer_class = OrderSerializer
permission_classes = [permissions.IsAuthenticated, IsEditor]
# Attack: an authenticated user with role "editor" requests PUT /orders/{someoneElsesId}/
# Result: has_permission passes because the role matches, and the unoverridden
# has_object_permission defaults to True for every object
Why this is vulnerable: DRF splits the decision in two and only one half is implemented. has_permission answers whether the caller may use the view at all; has_object_permission answers whether they may touch this record - and BasePermission supplies a default that returns True, so omitting it grants rather than denies.
That default is the trap. A permission class with a thoughtful has_permission looks complete, passes review, and silently approves every object, because the missing method is not missing at runtime. Overriding has_object_permission explicitly - even to return obj.owner == request.user - is what turns the inherited allow into a decision.
Denylist Role Comparison
# VULNERABLE - denylist fails open on any role value not explicitly excluded
BLOCKED_ROLES = {"guest", "viewer"}
def delete_order(request, order_id):
if request.user.role in BLOCKED_ROLES:
return HttpResponseForbidden()
# Every other role reaches this line: "Support" (added later), "Guest"
# (case mismatch against the blocked value), and a missing role attribute.
Order.objects.filter(id=order_id).delete()
return HttpResponse(status=204)
# Attack: authenticate with role = "support" (a value the list never anticipated)
# Result: "support" is not in BLOCKED_ROLES, so the delete proceeds with no
# role actually enforced
Why this is vulnerable: The structure is a denylist: it names the role that is refused and admits everything else, so a role added later is permitted by default with no code change.
Python contributes two further openings. String comparison is case-sensitive, so "Admin" is not "admin" and passes; and a user object without a role attribute raises AttributeError rather than denying - which turns into a 500, and in code where the lookup is getattr(user, "role", None) turns into a silent allow. An allowlist of permitted roles refuses each of these without anticipating them.
Check Missing on a Sibling View
# VULNERABLE - the ownership check exists on the generic detail view but
# a custom bulk-action view added later never calls check_object_permissions
class OrderDetailView(generics.RetrieveUpdateDestroyAPIView):
queryset = Order.objects.all()
permission_classes = [permissions.IsAuthenticated, IsAllowedRoleAndOwner]
# get_object() calls check_object_permissions() automatically.
class BulkArchiveView(APIView):
permission_classes = [permissions.IsAuthenticated, IsAllowedRoleAndOwner]
def post(self, request):
# Custom APIView - has_permission runs, but nothing here calls
# self.check_object_permissions(request, obj) for each order, so
# has_object_permission (and therefore ownership) is never checked.
order_ids = request.data.get("order_ids", [])
Order.objects.filter(id__in=order_ids).update(archived=True)
return Response(status=204)
Why this is vulnerable: permission_classes is set identically on both views, which is what makes this so easy to miss - the security configuration looks the same and the behaviour is not. DRF calls check_object_permissions() from get_object(), so a generic view gets object-level checks for free and an APIView that queries the ORM directly never triggers them.
The queryset is the second half. Order.objects.filter(id__in=order_ids) selects by identifiers the caller supplied, with no clause tying them to the caller, so the update reaches every row named regardless of owner. Scoping the queryset - Order.objects.filter(owner=request.user, id__in=order_ids) - makes the ownership constraint part of the query rather than a check that can be skipped, which is the more durable form of this fix.
Secure Patterns
Permission Class Implementing Both Checks
# SECURE - role allowlist at has_permission, ownership at has_object_permission
from rest_framework import generics, permissions
ALLOWED_ROLES = {"admin", "editor"}
class IsAllowedRoleAndOwner(permissions.BasePermission):
def has_permission(self, request, view):
# Coarse role check: explicit allowlist, not a `!=` denylist.
return getattr(request.user, "role", None) in ALLOWED_ROLES
def has_object_permission(self, request, view, obj):
if request.user.role == "admin":
return True
# Ownership compared against the loaded object, not request data.
return obj.owner_id == request.user.id
class OrderDetailView(generics.RetrieveUpdateDestroyAPIView):
queryset = Order.objects.all()
serializer_class = OrderSerializer
permission_classes = [permissions.IsAuthenticated, IsAllowedRoleAndOwner]
# get_object() calls check_object_permissions() automatically, so
# has_object_permission runs for GET, PUT, PATCH, and DELETE.
Why this works: has_permission uses an explicit allowlist (in ALLOWED_ROLES), so a role value not on the list is denied by default rather than requiring every unwanted value to be enumerated. has_object_permission compares obj.owner_id - a field loaded from the database via get_object() - against request.user.id, so a caller with an allowed role still cannot act on an object they do not own. Because RetrieveUpdateDestroyAPIView's get_object() calls check_object_permissions() internally, the ownership check runs automatically for every HTTP method the generic view exposes, without needing to be repeated per method.
Explicit Object Permission Check in a Custom View
# SECURE - a custom APIView explicitly calls check_object_permissions for
# every object it touches, since it does not inherit that behavior for free
from rest_framework.views import APIView
from rest_framework.response import Response
from django.shortcuts import get_object_or_404
class BulkArchiveView(APIView):
permission_classes = [permissions.IsAuthenticated, IsAllowedRoleAndOwner]
def post(self, request):
order_ids = request.data.get("order_ids", [])
orders = Order.objects.filter(id__in=order_ids)
for order in orders:
# A custom APIView must call this explicitly for each object -
# unlike generic views, it is not invoked automatically.
self.check_object_permissions(request, order)
orders.update(archived=True)
return Response(status=204)
Why this works: check_object_permissions() runs has_object_permission for every permission class in permission_classes, but DRF only calls it automatically from generic views' get_object(). A hand-written APIView must call it explicitly for each object it acts on - calling it once per order in the batch means one unowned ID anywhere in the request is enough to reject the whole batch, closing the gap that let the earlier bulk endpoint skip ownership entirely.
Framework-Specific Guidance
Django REST Framework ViewSets
A ModelViewSet routes both detail actions (retrieve, update, partial_update, destroy) and list to the same permission classes, and only the detail actions get object-level checks. list never loads a single object, so it never calls get_object(), so has_object_permission never runs - DRF documents this as a limitation of object-level permissions. With queryset = Order.objects.all(), GET /orders/ returns every order in the table to anyone whose role passes has_permission.
# VULNERABLE - has_object_permission protects retrieve/update/destroy, and
# does nothing at all for list. The queryset is the whole table.
class OrderViewSet(viewsets.ModelViewSet):
queryset = Order.objects.all()
serializer_class = OrderSerializer
permission_classes = [permissions.IsAuthenticated, IsAllowedRoleAndOwner]
# Attack: an authenticated "editor" requests GET /orders/ with no ID at all
# Result: 200 with every order in the database. Requesting one of those IDs
# individually is correctly refused with 403, which is what makes this easy
# to miss - the endpoint that leaks the data is not the one being tested
Scope the queryset instead. Ownership then lives in the query, which every action shares, rather than in a hook only some actions call:
# SECURE - the queryset itself is scoped to the caller, so list returns only
# their rows and every other action inherits the same constraint
from rest_framework import viewsets
class OrderViewSet(viewsets.ModelViewSet):
serializer_class = OrderSerializer
permission_classes = [permissions.IsAuthenticated, IsAllowedRoleAndOwner]
def get_queryset(self):
queryset = Order.objects.all()
if self.request.user.role == "admin":
return queryset
# Applies to list *and* to the lookup get_object() performs, so
# has_object_permission becomes a second line rather than the only one
return queryset.filter(owner=self.request.user)
Why this works: every action on a ModelViewSet reads its rows through get_queryset() - list filters it, and get_object() looks up the detail row inside it - so a constraint expressed there cannot be skipped by an action that does not call check_object_permissions(). has_object_permission stays in place as defence in depth: keeping both means a future action that bypasses the queryset is still caught, and a permission class edit that loosens ownership still leaves the query scoped.
There is a second effect worth having on purpose. Once the queryset is scoped, get_object() raises Http404 for another user's ID, because that row is not in the queryset it searches - so "does not exist" and "not yours" become the same 404 response with no code deciding to make them match. Returning 403 for a row that exists but is not yours confirms the ID is real, which turns a list endpoint you just closed into an enumeration oracle one request at a time.
Verified against Django 6.1 and DRF 3.18:
alice and bob are editors; order 3 belongs to bob
queryset = Order.objects.all()
GET /orders/ as alice 200, 3 rows - including bob's
permission calls: has_permission only
GET /orders/3/ as alice 403 - the detail route is correctly denied,
which is what makes the list leak easy to miss
get_queryset() filtered to the caller
GET /orders/ as alice 200, 2 rows - alice's only
GET /orders/3/ as alice 404
DELETE /orders/3/ as alice 404
GET /orders/ as admin 200, 3 rows - the admin branch still works
GET /orders/3/ as admin 200
Testing
- Role boundary: call the endpoint with
request.user.roleset to a value not inALLOWED_ROLESand confirm403, not a successful response. - Cross-owner access: authenticate as one user and target another user's object ID through every method the view exposes -
GET,PUT,PATCH,DELETE- confirming each is independently denied. - Object-permission coverage: for any custom
APIView, write a test confirmingcheck_object_permissionsis actually invoked - a permission class that is correct in isolation is still bypassed if the view never calls it. - List scope: as a non-admin,
GETthe collection endpoint with rows belonging to another user present in the table, and assert the response contains only the caller's rows. A403on the detail route for those same rows does not imply the list route excludes them. - Batch boundary: for bulk endpoints, include one object the caller does not own among several they do, and confirm the whole batch is rejected rather than partially processed.
- Use DRF's
APITestCasewithself.client.force_authenticate(user=...)to exercise the real permission chain, not just the permission class methods in isolation.
Common Pitfalls
- Implementing
has_permissionand assuming it covers object access:has_permissionruns before the object is loaded and can only express coarse, role-level rules; per-instance ownership must be enforced separately inhas_object_permission. - Writing a custom
APIViewwithout callingcheck_object_permissions: UnlikeModelViewSet/generic views, a hand-writtenAPIViewdoes not call this automatically - any object-level permission class added to it silently does nothing unless the view callsself.check_object_permissions(request, obj)itself. - Fixing the generic detail view but leaving a custom bulk or export view unprotected: A bulk-action endpoint is often added later and reuses the same permission class list without realizing that class's ownership check depends on a call the bulk view never makes.
- Relying on
has_object_permissionfor a list action: DRF only calls it fromget_object(), solistis unaffected by it entirely. An unscopedqueryseton aModelViewSetreturns every row to anyone who passeshas_permission, while the detail route for those same rows correctly returns403- filter inget_queryset()instead. - Resolving role from request data instead of
request.user:request.data.get('role')can be set to anything by the client; onlyrequest.user, populated by DRF's authentication classes from a verified session or token, is a trustworthy source for the role and identity used in the permission decision.