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 excludeis_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_idfrom the URL, so a caller who could only ever set three harmless fields could still set them on anybody's account. Taking the target fromrequest.userremoves the choice rather than checking it - if the endpoint genuinely has to accept an id, compare it againstrequest.userand return 404 rather than 403 on a mismatch. - It says nothing about the values.
setattr()writes straight past field validators, soemailaccepts 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 explicitfieldslist 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
nullvalues, 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
UserCreateSerializerto use an explicitfieldslist, while a separateUserAdminSerializeror bulk-update view still usesfields = '__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_fieldswithout also adding it tofieldscorrectly, or adding it tofieldson one serializer but forgetting a second serializer used by a different viewset for the same model (e.g., aPATCH-onlyUserUpdateSerializer) - 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 ofextra = 'forbid', then assuming that's equivalent protection to explicitly listing permitted fields -ignoredoes stop unknown fields from being set, but a field that is declared on the model (likeis_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_FIELDSbefore asetattr()loop, but definingALLOWED_UPDATE_FIELDSwith 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.