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 (forms/views), unsafe patterns include ModelForm with fields="__all__" or direct setattr/**kwargs updates. In Django REST Framework (DRF), risks come from permissive serializers (e.g., fields = '__all__') or mapping request.data directly to models. This guidance targets modern Django and DRF. Always use serializers with explicit fields or exclude lists, never use fields = '__all__' for input, validate with Django validators, and never update models directly from request.data.
Primary Defence: Use Django REST Framework serializers with explicit fields lists, validate with validators, use read_only_fields for protected attributes, and never use 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., usingEmailValidator,MinLengthValidator). Both protections are essential.
Common Vulnerable Patterns
Using fields = '__all__' in Serializers
# VULNERABLE - fields = '__all__' exposes all model fields
from rest_framework import serializers
from .models import User
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = '__all__' # Exposes ALL fields including is_staff, is_superuser!
# Views
from rest_framework import viewsets
class UserViewSet(viewsets.ModelViewSet):
queryset = User.objects.all()
serializer_class = UserSerializer
# 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- Attackers can set
is_staff,is_superuser,is_active, or any field - Enables immediate privilege escalation
- No distinction between user-controlled and system-controlled fields
- Django REST Framework will accept and assign all provided fields
Direct Model Creation from request.data
# VULNERABLE - Creating models directly from request data
from django.views import View
from django.http import JsonResponse
import json
class CreateUserView(View):
def post(self, request):
data = json.loads(request.body)
# Directly passes all request data to model!
user = User.objects.create(**data)
return JsonResponse({'id': user.id, 'username': user.username})
# Attack: POST /users/
# { "username": "attacker", "email": "attacker@evil.com", "is_staff": true, "balance": 999999 }
Why this is vulnerable:
**dataunpacks all dictionary keys as keyword arguments- No filtering of which fields are allowed
- Bypasses all serializer validation and protection
- Can set any model field including security-critical ones
Using setattr Without Field Filtering (DRF)
# VULNERABLE - setattr allows arbitrary field setting
from django.forms.models import model_to_dict
@api_view(["PATCH"])
def update_user(request, user_id):
user = User.objects.get(id=user_id)
# Sets all attributes from request data!
for key, value in request.data.items():
setattr(user, key, value)
user.save()
return Response(model_to_dict(user))
# Attack: PATCH /users/123/
# { "email": "new@example.com", "is_staff": true, "is_superuser": true }
Why this is vulnerable:
setattr()sets any attribute by name from user input- No allowlist of permitted fields
- Can modify any model field dynamically
- Bypasses model validation and permissions
Django Forms with fields = "__all__"
# VULNERABLE - ModelForm with all fields
from django import forms
class UserForm(forms.ModelForm):
class Meta:
model = User
fields = "__all__" # Includes ALL fields!
def update_user_view(request, user_id):
user = User.objects.get(id=user_id)
form = UserForm(request.POST, instance=user)
if form.is_valid():
form.save() # Saves all fields from POST data!
return JsonResponse({'status': 'updated'})
return JsonResponse({'errors': form.errors}, status=400)
# Attack: POST data includes is_staff=True, is_superuser=True
Why this is vulnerable:
fields = "__all__"includes every model field in the form- Form will accept and save any field present in POST data
- No protection against setting security-critical fields
Pydantic Models Without Field Config
# VULNERABLE - Pydantic model allowing extra fields
from pydantic import BaseModel
from typing import Optional
class UserCreate(BaseModel):
username: str
email: str
password: str
is_admin: Optional[bool] = False # Should not be in user input model!
class Config:
extra = 'allow' # Allows additional fields not in model!
# FastAPI endpoint
from fastapi import FastAPI
@app.post("/users/")
def create_user(user: UserCreate):
# user.is_admin can be set from request!
db_user = User(**user.dict())
db.add(db_user)
db.commit()
return db_user
# Attack: POST /users/
# { "username": "attacker", "email": "attacker@evil.com", "is_admin": true }
Why this is vulnerable:
- Including
is_adminin the input model allows users to set it extra = 'allow'permits fields not defined in the model- Pydantic will accept and pass through security-critical fields
- Should use separate models for input vs. database entities
Secure Patterns
Use Explicit Fields in Serializers
# SECURE - Explicit field list in serializer
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]
)
password_confirm = serializers.CharField(write_only=True, required=True)
class Meta:
model = User
# Explicit list - ONLY these fields can be set
fields = ['username', 'email', 'password', 'password_confirm']
# Alternative: exclude = ['is_staff', 'is_superuser', 'is_active', 'balance']
def validate(self, attrs):
if attrs['password'] != attrs['password_confirm']:
raise serializers.ValidationError(
{"password": "Password fields didn't match."}
)
return attrs
def create(self, validated_data):
validated_data.pop('password_confirm')
user = User.objects.create_user(
username=validated_data['username'],
email=validated_data['email'],
password=validated_data['password']
)
# Explicitly set secure defaults
user.is_staff = False
user.is_superuser = False
user.is_active = True
user.balance = 0
user.save()
return user
class UserResponseSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['id', 'username', 'email', 'date_joined']
# Never expose password, is_staff, is_superuser, etc.
Why this works:
- Explicit field list: Only
username,email,passwordcan be deserialized - write_only fields: Passwords are never included in responses
- Separate serializers: Different serializers for create/update/response
- Secure defaults: Code explicitly sets
is_staff,is_superuserto safe values - Field validation: Password validation enforced
- No '
__all__': Never usesfields = '__all__'
Use read_only_fields for Protected Attributes
# SECURE - read_only_fields prevents mass assignment
class UserUpdateSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['id', 'username', 'email', 'display_name', 'bio', 'is_staff', 'balance']
read_only_fields = ['id', 'is_staff', 'is_superuser', 'balance', 'date_joined']
# Even if included in fields, read_only_fields cannot be written
class UserViewSet(viewsets.ModelViewSet):
queryset = User.objects.all()
def get_serializer_class(self):
if self.action == 'create':
return UserCreateSerializer
elif self.action in ['update', 'partial_update']:
return UserUpdateSerializer
return UserResponseSerializer
Why this works:
- read_only_fields: Even if attacker sends
is_staff=true, it's ignored - Action-based serializers: Different serializers for different operations
- Explicit protection: Security-critical fields marked read-only
- Documentation: Clear which fields are protected
Separate Serializers for Different Operations
# SECURE - Separate serializers for different operations
from rest_framework import serializers, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated, IsAdminUser
from django.contrib.auth.hashers import check_password
class UpdateProfileSerializer(serializers.Serializer):
display_name = serializers.CharField(max_length=100, required=True)
bio = serializers.CharField(max_length=500, allow_blank=True, required=False)
website = serializers.URLField(required=False, allow_blank=True)
class UpdateEmailSerializer(serializers.Serializer):
new_email = serializers.EmailField(required=True)
current_password = serializers.CharField(required=True, write_only=True)
def validate_current_password(self, value):
user = self.context['request'].user
if not check_password(value, user.password):
raise serializers.ValidationError("Invalid password")
return value
class UpdateRoleSerializer(serializers.Serializer):
role = serializers.CharField(max_length=50, required=True)
reason = serializers.CharField(max_length=500, required=True)
class UserViewSet(viewsets.ModelViewSet):
queryset = User.objects.all()
permission_classes = [IsAuthenticated]
@action(detail=True, methods=['patch'], permission_classes=[IsAuthenticated])
def update_profile(self, request, pk=None):
user = self.get_object()
# Users can only update their own profile
if user != request.user:
return Response({'error': 'Forbidden'}, status=status.HTTP_403_FORBIDDEN)
serializer = UpdateProfileSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user.display_name = serializer.validated_data['display_name']
user.bio = serializer.validated_data.get('bio', '')
user.website = serializer.validated_data.get('website', '')
user.save()
return Response(UserResponseSerializer(user).data)
@action(detail=True, methods=['patch'], permission_classes=[IsAuthenticated])
def update_email(self, request, pk=None):
user = self.get_object()
if user != request.user:
return Response({'error': 'Forbidden'}, status=status.HTTP_403_FORBIDDEN)
serializer = UpdateEmailSerializer(data=request.data, context={'request': request})
serializer.is_valid(raise_exception=True)
user.email = serializer.validated_data['new_email']
user.email_verified = False # Require re-verification
user.save()
# Send verification email...
return Response({'message': 'Email updated, verification required'})
@action(detail=True, methods=['patch'], permission_classes=[IsAdminUser])
def update_role(self, request, pk=None):
user = self.get_object()
serializer = UpdateRoleSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
# Audit role changes
AuditLog.objects.create(
user=user,
action='role_change',
new_role=serializer.validated_data['role'],
reason=serializer.validated_data['reason'],
changed_by=request.user
)
user.role = serializer.validated_data['role']
user.save()
return Response(UserResponseSerializer(user).data)
Why this works:
- Operation-specific serializers: Each operation has minimal field exposure
- Minimal fields: Profile updates can't change email, email updates can't change role
- Permission classes: Different operations require different permissions
- Password verification: Email updates require password re-entry
- Audit trail: Role changes logged with reason and actor
- Authorization: Custom permission checks ensure users can only modify own data
Django Forms with Explicit Fields
# SECURE - ModelForm with explicit field list
from django import forms
from django.core.exceptions import ValidationError
class UserCreateForm(forms.ModelForm):
password_confirm = forms.CharField(widget=forms.PasswordInput)
class Meta:
model = User
# Explicit field list - ONLY these fields
fields = ['username', 'email', 'password']
widgets = {
'password': forms.PasswordInput(),
}
def clean_password_confirm(self):
password = self.cleaned_data.get('password')
password_confirm = self.cleaned_data.get('password_confirm')
if password and password_confirm and password != password_confirm:
raise ValidationError("Passwords don't match")
return password_confirm
def save(self, commit=True):
user = super().save(commit=False)
user.set_password(self.cleaned_data['password'])
user.is_staff = False # Explicit secure defaults
user.is_superuser = False
user.is_active = True
if commit:
user.save()
return user
# In views
from django.views.generic.edit import CreateView
class UserCreateView(CreateView):
model = User
form_class = UserCreateForm
template_name = 'user_form.html'
success_url = '/users/'
Why this works:
- Explicit fields: Only specified fields included in form
- Field validation: Password confirmation validated
- Secure defaults: Form explicitly sets protected fields
- No mass assignment: Form controls which fields are settable
- Password hashing:
set_password()properly hashes password
Pydantic with Separate Input/Output Models
# SECURE - Separate Pydantic models for input and output
from pydantic import BaseModel, EmailStr, validator
from typing import Optional
from datetime import datetime
# Input model - only user-controllable fields
class UserCreate(BaseModel):
username: str
email: EmailStr
password: str
class Config:
extra = 'forbid' # Reject extra fields
@validator('username')
def username_length(cls, v):
if len(v) < 3 or len(v) > 50:
raise ValueError('Username must be 3-50 characters')
return v
@validator('password')
def password_strength(cls, v):
if len(v) < 8:
raise ValueError('Password must be at least 8 characters')
return v
# Output model - only safe fields to return
class UserResponse(BaseModel):
id: int
username: str
email: EmailStr
created_at: datetime
class Config:
orm_mode = True
# Never include password, is_admin, balance, etc.
# Database model
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Numeric
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()
class User(Base):
__tablename__ = 'users'
id = Column(Integer, primary_key=True)
username = Column(String(50), unique=True, nullable=False)
email = Column(String(100), unique=True, nullable=False)
password_hash = Column(String(255), nullable=False)
is_admin = Column(Boolean, default=False, nullable=False)
balance = Column(Numeric(10, 2), default=0, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
# FastAPI endpoint
from fastapi import FastAPI, HTTPException, Depends
from passlib.context import CryptContext
app = FastAPI()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
@app.post("/users/", response_model=UserResponse)
def create_user(user_input: UserCreate, db: Session = Depends(get_db)):
# Check if user exists
existing = db.query(User).filter(User.email == user_input.email).first()
if existing:
raise HTTPException(status_code=400, detail="Email already registered")
# Create user with secure defaults
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
balance=0
)
db.add(db_user)
db.commit()
db.refresh(db_user)
return db_user
Why this works:
- Separate models: Input model only has user-controllable fields
- extra = 'forbid': Rejects requests with extra fields
- Field validators: Pydantic validates each field
- Password hashing: Passwords properly hashed before storage
- Explicit defaults: Code sets
is_admin,balanceto secure values - Response filtering: Output model only includes safe fields
Using Django's update_fields
# SECURE - update_fields explicitly limits what gets saved
from rest_framework import viewsets, status
from rest_framework.response import Response
class UserViewSet(viewsets.ModelViewSet):
queryset = User.objects.all()
def partial_update(self, request, pk=None):
user = self.get_object()
# Allowlist of updateable fields
allowed_fields = {'email', 'display_name', 'bio'}
# Filter request data to only allowed fields
update_data = {
key: value
for key, value in request.data.items()
if key in allowed_fields
}
# Validate each field
serializer = UserUpdateSerializer(user, data=update_data, partial=True)
serializer.is_valid(raise_exception=True)
# Save with explicit field list
user = serializer.save()
user.save(update_fields=list(update_data.keys()))
return Response(UserResponseSerializer(user).data)
Why this works:
- Field allowlist: Only permitted fields are processed
- Dictionary comprehension: Filters request data before deserialization
- update_fields: Explicitly tells Django which fields to UPDATE in SQL
- Defense-in-depth: Even if serializer misconfigured, field filter protects
- SQL safety: Generated SQL only UPDATEs specified columns