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 (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 exclude is_admin), while Django validators validate the values of allowed fields (e.g., using EmailValidator, 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:

  • **data unpacks 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_admin in 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, password can 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_superuser to safe values
  • Field validation: Password validation enforced
  • No '__all__': Never uses fields = '__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, balance to 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

Additional Resources