In recent years, software applications have become more robust, allowing for more complex data queries and accessibility. With that in mind, the number of applications vulnerable to attacks is also growing. Some infamous security incidents have occurred. One example is the T-Mobile phone number querying that led to sensitive data exposure. Facebook and Uber have also experienced their own fair share of object-level security flaws. Large, medium and small companies are all at risk of security attacks that can negatively impact the business.
In this post, we'll define broken object-level authorization. We'll provide some instances and discuss some of the methods we can use to make our Django apps more secure. Hopefully, you'll be able to apply what you've learned to your Django projects.
Broken Object-Level Authorization
Object-level authorization is a security or access control mechanism that checks authenticated user privileges against the content or resource instance they're trying to access.
This type of control usually occurs when trying to perform an action on a particular object in a resource. For example, suppose we have a User Account resource, and one wants to access a single user account detail with a unique ID 4. Access control in the given example is usually at the object level.
In most cases, the user can log in and access user accounts; however, the types of accounts that may be viewed are limited. For example, only the account owner has access to their own data, or administrators have access to all user data.
Object-level authorization provides finer control over what actions a user may take on each resource object. An object is any information to which the application has access.
Broken object-level authorization can be described as any security vulnerability on resources at the object level. Because object-level authorization allows for finer-grained rights, any application with an object-level authorization vulnerability risks exposing sensitive information to attackers.
API-based apps are the most vulnerable to object-level authorization attacks. This is because APIs are designed to facilitate different operations on objects such as create, read, update, and delete. If the authorization around these objects is broken, we're at risk of a broken object-level authorization vulnerability.
Examples of Broken Object-Level Authorization
The following scenario illustrates an example of such an attack. Suppose we have an API endpoint www.example.com/api/messages/<ID>/ in the request header. An attacker can modify the ID information to something else and get access to the data.
For example, the attacker can manually modify the ID in the URL to something else like 67. Such that www.example.com/api/messages/67/ can show the message details for the given ID if it exists in the database. Some of the message details could be the sender, the receiver, and the contents.
Another situation is if a POST or PUT request contains a unique ID that is changeable or put in the request body, an attacker might use that instance to change the resource object's identifier. For example, they might change the following JSON object request to www.example.com/api/contacts/:
{
"user": 23,
"contact": {
"full_name": "Chike Ada",
"email": "test@example.com"
},
"reference": "Adding a new contact to account"
}
Also consider the following scenario: predictable and poorly structured API resource object IDs. Using auto-incrementing numbers—like 1…2…3, as an example—an attacker can simply figure out the API structure and manipulate these resource objects. For example, the user with ID 23 can have their data updated to something else by an authenticated attacker as seen below.
{
"user": 23,
"contact": {
"full_name": "Conartist Ada-lovelace",
"email": "malicious@example.com"
},
"reference": "Attacker updated the user with ID 23 contact details"
}
When the owner of this contact object logs into the system, they discover different data, which is a risk. They're likely to lose all of their original data or have their data integrity affected.
Preventions to Broken Object-Level Authorization
Because broken object-level authorization affects businesses, knowing how to prevent such attacks will save businesses from losing sensitive data. In this section, we'll discuss various ways to handle broken object-level authorization.
Randomly Generated Unique Identifiers
One recommendation is to use globally unique identifiers (GUIDs) or universally unique identifiers (UUIDs) to reduce threats from predictable object resource identifiers. UUIDs are more commonly used because they generate large values (typically 128-bit random integers) with a low chance of colliding. A collision occurs when two or more identifiers point to the same object. An example of a UUID string is 092a5461-e71b-32d1-e544-792314582966.
To generate a UUID, run the following:
pip install uuid
In models.py, run the below:
import uuid
from django.db import models
class Subscription(models.Model):
id = models.UUIDField(unique=True, primary_key=True, editable=False, default=uuid.uuid4)
...
Using UUIDs makes it more difficult for attackers to guess the resource object identifier.
Django Groups
Aside from the usage of UUIDs, it's critical to establish some kind of access control on the types of data that both authorized and unauthenticated users can act on. One of the ways of establishing access control is by grouping users in Django. This ensures that a certain group of users can perform a certain action, hence helping in managing access.
By using a Django Group, we can restrict access to resource objects. For example, in the managers.py file, run the following:
from django.contrib.auth.models import UserManager, Group
class CustomUserManager(UserManager):
use_in_migration = True
def _create_user(self, email, password, **extra_fields):
"""Create user with a given email and password"""
if not email or not password:
raise ValueError("Users must have an email and password")
user = self.model(email=self.normalize_email(email), **extra_fields)
user.set_password(password)
user.save(using=self._db)
if extra_fields["is_supplier"] == True:
self._add_user_to_group("supplier", user)
...
def _add_user_to_group(self, group_name, user):
user_group, _ = Group.objects.get_or_create(name=group_name)
user_group.user_set.add(user)
...
We can create a custom user manager that includes a Django Group. Depending on the user role, such as a supplier, we can assign them to groups on user creation in the code snippet above. In models.py, run the below:
import uuid
from django.db import models
from django.contrib.auth.models import AbstractUser
from managers.py import CustomUserManager
class User(AbstractUser):
id = models.UUIDField(primary_key=True, editable=False, default=uuid.uuid4)
...
objects = CustomUserManager()
We can then use the custom user manager in the User resource. Although not many changes occur for object-level authorization, we have successfully created permission groups for all users signing up on the platform, which is a step in access control on resource objects. In business logic, we can limit the content to users in the supplier group. For example, using the Django REST framework in views.py, run the following:
from rest_framework import viewsets, status, permissions
from rest_framework.response import Response
...
class ReportView(viewset.ModelViewSet):
queryset = Report.objects.all()
permission_classes = [permissions.IsAuthenticated]
def list(self, request):
if request.user.groups.filter(name="supplier").exists():
# PERFORM ACTIONS FOR SUPPLIERS ONLY
return Response({
"detail": "You do not have permission to perform this action"
}, status=status.HTTP_403_FORBIDDEN)
The code snippet filters authenticated users against user groups. If the user doesn't belong to the supplier group, they cannot access the resource content.
We can note that if the user belongs to the supplier group, they can access the data whether they're the owners of the resource object or not.
Resource Ownership
To ensure no data leakage to another user, we store information about the creator of that resource object in the object. For example, say a user who belongs to a supplier group creates an instance of a report. To track who owns the report instance, we add a field named "user" or "owner" to the resource data, as seen below.
In models.py, run the following:
import uuid
from django.db import models
class Report(models.Model):
id = models.UUIDField(primary_key=True, editable=False, default=uuid.uuid4)
owner = models.ForeignKey("User", on_delete=models.CASCADE, related_name=reports)
title = models.CharField(max_length=250)
...
Here, we attach the report to its owner by assigning the foreign key to the User resource.
{
"owner": "as34-ade42315d-893a-0934d",
"title": "An unsecure report that exposes the owner ID"
}
Now, the front end will provide the JSON object like the one shown above.
Extract ID From Auth Token
To prevent the front end from providing the report creator, we derive it from the authenticated user. We update our views.py as shown below.
from rest_framework import viewsets, status, permissions
from rest_framework.response import Response
...
class ReportView(viewset.ModelViewSet):
queryset = Report.objects.all()
permission_classes = [permissions.IsAuthenticated]
serializer_class = ReportSerializer
def create(self, request):
serializer = self.serializer_class(request.data)
if serializer.is_valid(raise_exception=True):
serializer.save(user=request.user)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.error_messages, status=status.HTTP_400_BAD_REQUEST)
The front-end client will only need to give the report data rather than explicitly giving the "owner" field data.
{
"title": "This report no longer requires the owner ID on creation"
...
}
Query by Ownership
Although the report now belongs to a specific user, we're still retrieving all reports on the platform regardless of ownership. To prevent viewing others' reports, we can filter queries by ownership as shown below.
from rest_framework import viewsets, status, permissions
from rest_framework.response import Response
...
class ReportView(viewset.ModelViewSet):
queryset = Report.objects.all()
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
if not request.user.is_superuser:
return Report.objects.filter(user=request.user)
return super().get_queryset()
The code snippet makes sure that only reports belonging to the authenticated user are available. If the user is a superuser (that is, an admin), the authenticated user can view all reports. Another approach to tackling object-level authorization is by extending the base permission available in Django (the Django REST framework) as seen below.
In permissions.py, run the below:
from rest_framework import permissions
class IsOwnerOrAdmin(permissions.BasePermission):
def has_permission(self, request, view):
# This method only allows suppliers or admin to view this route.
return request.user.groups.filter(name="supplier") or request.user.is_superuser
def has_object_permission(self, request, view, obj):
# This method authorizes only the resource owner or admin from using object.
return obj.owner == request.user or request.user.is_superuser
In the snippet above, the has_permission() method is in charge of authorizing users to view or call a particular resource or endpoint. The has_object_permission() method is responsible for checking that a certain user can access a particular object in a given resource. In the example above, only users in the supplier group or administrators can view the resource or route. In order to perform an action on a particular object in the resource, one has to be the object owner or an administrator on the platform.
To use this, in views.py, run the following:
from rest_framework import viewsets, status, permissions
from rest_framework.response import Response
from .permissions import IsOwnerOrAdmin
...
class ReportView(viewset.ModelViewSet):
queryset = Report.objects.all()
permission_classes = [permissions.IsAuthenticated, IsOwnerOrAdmin]
...
Note that now the URL, www.example.com/api/reports/, on the GET request will still show all reports to the authenticated users in the supplier group. Although, performing any action on a particular resource object itself will be prohibited if the authenticated user isn't the object owner. To make sure it only shows the authenticated user who owns the report, we must include that in the query set, as shown below.
from rest_framework import viewsets, status, permissions
from rest_framework.response import Response
from .permissions import IsOwnerOrAdmin
...
class ReportView(viewset.ModelViewSet):
queryset = Report.objects.all()
permission_classes = [permissions.IsAuthenticated, IsOwnerOrAdmin]
...
def get_queryset(self):
if not request.user.is_superuser:
return Report.objects.filter(user=request.user)
return super().get_queryset()
These are some of the known methods to prevent broken object-level authentication in Django APIs.
Trust Is Earned
As attackers continue to uncover flaws in every system's security, we must ensure that users' data is protected by authenticating its source. We should never put our faith in user-provided data. To avoid catastrophes from storing dangerous data in our businesses, we must vet data.
We learned in this post how attackers try to get authorization to resource objects by changing their resource IDs with a new one that is inferred from predictable API ID structures. Endpoints that don't test authenticated user permissions before authorizing certain resource operations may provide attackers access to the resource.
We considered various approaches to combat these known broken object-level authorization vulnerabilities by encouraging the use of UUIDs that generate long strings of low-collision IDs. We also demonstrated ways to prevent exposing other users' data by attaching owner data to the resource object to be accessed during querying.
This post was written by Ifenna Okoye. Ifenna is a software developer with a particular interest in backend technologies including Python and Node.js.