Skip to content

CWE-915: Improperly Controlled Modification of Dynamically-Determined Object Attributes - Python

Overview

Mass assignment vulnerabilities in Python occur when request data is mapped directly to model attributes without an allowlist. In Django REST Framework (DRF), risks come from permissive serializers (fields = '__all__') or mapping request.data directly to models. In FastAPI, risks come from Pydantic input models that include server-controlled fields or allow extra, undeclared fields. This guidance targets modern Django, DRF, and FastAPI/Pydantic.

Primary Defence: Use DRF serializers or Pydantic models with explicit fields lists - never fields = '__all__' for input - mark server-controlled attributes read_only (DRF) or omit them from the input model entirely (Pydantic), and never call Model.objects.create(**request.data).

Defense-in-depth: CWE-915 (mass assignment) controls which fields can be set (e.g., using fields = ['email', 'username'] to exclude is_admin), while Django validators validate the values of allowed fields (e.g., EmailValidator, MinLengthValidator). Both protections are essential.

Common Vulnerable Patterns

fields = '__all__' in DRF Serializers

# VULNERABLE - fields = '__all__' exposes every model field, including is_staff/is_superuser
from rest_framework import serializers
from .models import User

class UserSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = '__all__'

# Attack: POST /api/users/
# { "username": "attacker", "email": "attacker@evil.com", "is_staff": true, "is_superuser": true }

Why this is vulnerable: fields = '__all__' includes every model field in the serializer with no distinction between user-controlled and system-controlled fields. DRF will deserialize and assign whatever the client sends, including is_staff and is_superuser.

Direct Model Creation from request.data

# VULNERABLE - unpacking request data straight into the model constructor
def post(self, request):
    data = json.loads(request.body)
    user = User.objects.create(**data)   # No filtering of which fields are allowed
    return JsonResponse({'id': user.id, 'username': user.username})

# Attack: { "username": "attacker", "email": "a@evil.com", "is_staff": true, "balance": 999999 }

Why this is vulnerable: **data unpacks every dictionary key as a keyword argument to the model constructor, bypassing serializer validation entirely and allowing any model field - including security-critical ones - to be set.

setattr() Without Field Filtering

# VULNERABLE - setattr sets any attribute named by the request
@api_view(["PATCH"])
def update_user(request, user_id):
    user = User.objects.get(id=user_id)
    for key, value in request.data.items():
        setattr(user, key, value)   # Sets ALL attributes from request data!
    user.save()
    return Response(model_to_dict(user))

# Attack: { "email": "new@example.com", "is_staff": true, "is_superuser": true }

Why this is vulnerable: setattr() sets any attribute by name from user input with no allowlist, bypassing model validation and permissions entirely - this is the canonical CWE-915 pattern.

Pydantic Models with extra = 'allow'

# VULNERABLE - is_admin in the input model, plus extra fields allowed
from pydantic import BaseModel

class UserCreate(BaseModel):
    username: str
    email: str
    password: str
    is_admin: bool = False   # Should not be in a user-facing input model!

    class Config:
        extra = 'allow'      # Also permits fields not declared on the model

@app.post("/users/")
def create_user(user: UserCreate):
    db_user = User(**user.dict())   # user.is_admin is attacker-controlled
    db.add(db_user)
    db.commit()
    return db_user

# Attack: { "username": "attacker", "email": "a@evil.com", "is_admin": true }

Why this is vulnerable: Including is_admin directly on the input model lets any caller set it, and extra = 'allow' means even undeclared fields pass through. Input models should only ever contain fields the caller is meant to control.

Secure Patterns

Explicit Fields in DRF Serializers

# SECURE - explicit field list; no '__all__'
from rest_framework import serializers
from django.contrib.auth.password_validation import validate_password
from .models import User

class UserCreateSerializer(serializers.ModelSerializer):
    password = serializers.CharField(write_only=True, required=True, validators=[validate_password])

    class Meta:
        model = User
        fields = ['username', 'email', 'password']   # ONLY these fields can be set

    def create(self, validated_data):
        user = User.objects.create_user(**validated_data)
        user.is_staff = False       # Explicit secure defaults
        user.is_superuser = False
        user.save()
        return user

Why this works: Only username, email, and password can be deserialized - there is no path from the request to is_staff or is_superuser because those fields are never declared on the serializer. password is write_only, so it is also never returned in a response.

read_only_fields for Protected Attributes

# SECURE - read_only_fields protects fields even when they are listed
class UserUpdateSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = ['id', 'username', 'email', 'display_name', 'bio', 'is_staff', 'balance']
        # Every name here also appears in fields - see below
        read_only_fields = ['id', 'is_staff', 'balance']

Why this works: A field can be included in fields (for example, so it appears in API responses) while still being protected from client writes via read_only_fields. Even if an attacker sends is_staff=true, DRF ignores it during deserialization.

read_only_fields only modifies fields that fields already declares. A name listed there and nowhere else - is_superuser, say - is silently discarded: DRF raises nothing, the serializer has no such field, and validated_data never contains it. That is safe, because a field absent from fields cannot be written either way, but it is not protection by read_only_fields, and reading it as though it were is how a field ends up believed covered by a line that does nothing. is_superuser is protected here by its absence from fields.

setattr() with an Allowlist

# SECURE - setattr checked against an explicit allowlist, on the caller's own record
ALLOWED_UPDATE_FIELDS = {'email', 'display_name', 'bio'}

@api_view(["PATCH"])
@permission_classes([IsAuthenticated])
def update_user(request):
    user = request.user   # not a caller-supplied id
    for key, value in request.data.items():
        if key not in ALLOWED_UPDATE_FIELDS:
            return Response({'error': f'Field "{key}" is not allowed'}, status=400)
        setattr(user, key, value)

    try:
        # Validate only the fields this endpoint touched
        user.full_clean(exclude={f.name for f in User._meta.fields
                                 if f.name not in ALLOWED_UPDATE_FIELDS})
    except ValidationError as exc:
        return Response(exc.message_dict, status=400)

    user.save(update_fields=ALLOWED_UPDATE_FIELDS & set(request.data))
    return Response({field: getattr(user, field) for field in ALLOWED_UPDATE_FIELDS})

Why this works: Every attribute name is checked against an explicit allowlist before setattr() runs, so dynamic, dictionary-driven updates can no longer reach is_staff, is_superuser, or any other field outside the allowlist.

Two things the allowlist does not do, both handled above:

  • It says nothing about whose record is being edited. The vulnerable version took user_id from the URL, so a caller who could only ever set three harmless fields could still set them on anybody's account. Taking the target from request.user removes the choice rather than checking it - if the endpoint genuinely has to accept an id, compare it against request.user and return 404 rather than 403 on a mismatch.
  • It says nothing about the values. setattr() writes straight past field validators, so email accepts anything the column will hold. full_clean() restores the validation a serializer would have run. Where the update is anything more than a handful of fields, a serializer with an explicit fields list is the better shape - this pattern is for the cases where the field set really is dynamic.

Pydantic with extra = 'forbid' and Separate Input/Output Models

# SECURE - separate models for input and output; unknown fields rejected
from pydantic import BaseModel, EmailStr, ConfigDict

class UserCreate(BaseModel):
    model_config = ConfigDict(extra='forbid')   # Reject any undeclared field

    username: str
    email: EmailStr
    password: str
    # is_admin, balance intentionally absent - not part of user input

class UserResponse(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    id: int
    username: str
    email: EmailStr
    # Never include password, is_admin, balance, etc.

@app.post("/users/", response_model=UserResponse)
def create_user(user_input: UserCreate, db: Session = Depends(get_db)):
    db_user = User(
        username=user_input.username,
        email=user_input.email,
        password_hash=pwd_context.hash(user_input.password),
        is_admin=False,   # Explicit secure default
    )
    db.add(db_user)
    db.commit()
    db.refresh(db_user)
    return db_user

Why this works: UserCreate has no is_admin field for an attacker to set, and extra='forbid' makes Pydantic raise a validation error if the request includes any field the model doesn't declare, rather than silently dropping or accepting it. The separate UserResponse model also prevents sensitive fields from leaking back to the client.

Framework-Specific Guidance

Django ModelForm

forms.ModelForm with fields = "__all__" has the same problem as DRF's fields = '__all__' - it exposes every model field to form.save(). Use an explicit fields list on class Meta, and set security-critical attributes explicitly after form.save(commit=False), before the final save().

Testing

  • Normal input: create and update records using only allowed fields and confirm expected fields persist.
  • Boundary input: submit unknown fields, nested objects, and null values, and confirm filtering behavior is consistent.
  • Malicious input: include is_staff, is_superuser, role, balance, or an ownership field; verify it is ignored or rejected and never saved.
  • Re-scan with the security scanner to confirm the finding is resolved.

Common Pitfalls

  • Fixing the UserCreateSerializer to use an explicit fields list, while a separate UserAdminSerializer or bulk-update view still uses fields = '__all__' for a "quick" admin panel - each serializer needs its own explicit field list; there's no shared, model-level protection unless every serializer touching that model is checked.
  • Adding a field to read_only_fields without also adding it to fields correctly, or adding it to fields on one serializer but forgetting a second serializer used by a different viewset for the same model (e.g., a PATCH-only UserUpdateSerializer) - DRF's protection is per-serializer-class, so each one is independently either safe or vulnerable.
  • Using Pydantic's default extra = 'ignore' behavior (silently dropping unknown fields) instead of extra = 'forbid', then assuming that's equivalent protection to explicitly listing permitted fields - ignore does stop unknown fields from being set, but a field that is declared on the model (like is_admin, even with a default) is still settable by any caller unless it's removed from the input model entirely.
  • Checking if key not in ALLOWED_UPDATE_FIELDS before a setattr() loop, but defining ALLOWED_UPDATE_FIELDS with a typo or stale entry that no longer matches the model's actual field names - since the check is just a plain set membership test with no schema validation behind it, a mismatch silently allows or blocks fields with no error surfaced during development.

Additional Resources