Back

API6:2019 Mass Assignment - Code Examples

Each pair below shows a vulnerable endpoint that binds the request body straight to a model, and the secure version that binds only an allow-list of fields. The scenario is the same throughout: a User model that carries client-editable fields (username, email) and server-controlled fields (role, is_verified, balance). The attack in every case is to append "role": "admin" (and friends) to the body.

Flask + SQLAlchemy (Python)

Vulnerable

from flask import Flask, request, jsonify
from models import db, User      # User has: username, email, role, is_verified, balance

app = Flask(__name__)

@app.route('/api/users', methods=['POST'])
def create_user():
    # The whole body is unpacked onto the model - every key becomes a column.
    user = User(**request.get_json())     # {"role": "admin"} is bound here
    db.session.add(user)
    db.session.commit()
    return jsonify(id=user.id, username=user.username, role=user.role), 201

@app.route('/api/users/me', methods=['PATCH'])
def update_me():
    user = current_user()
    for key, value in request.get_json().items():
        setattr(user, key, value)         # merges ANY field: is_verified, balance...
    db.session.commit()
    return jsonify(ok=True)

Why it's vulnerable: User(**body) and the setattr merge loop bind every client key. A request with "role": "admin" or "balance": 999999 writes those columns directly.

Secure

from flask import Flask, request, jsonify
from pydantic import BaseModel, EmailStr, ConfigDict, ValidationError
from models import db, User

app = Flask(__name__)

class UserCreateDTO(BaseModel):
    model_config = ConfigDict(extra='forbid')   # unknown keys -> error
    username: str
    email: EmailStr
    password: str

class UserUpdateDTO(BaseModel):
    model_config = ConfigDict(extra='forbid')
    display_name: str | None = None              # only editable fields exist here

@app.route('/api/users', methods=['POST'])
def create_user():
    try:
        dto = UserCreateDTO(**request.get_json())
    except ValidationError as e:
        return jsonify(error='invalid input', detail=e.errors()), 400

    user = User(username=dto.username, email=dto.email)
    user.set_password(dto.password)
    user.role = 'user'                # server sets sensitive fields itself
    user.is_verified = False
    user.balance = 0
    db.session.add(user); db.session.commit()
    return jsonify(id=user.id, username=user.username), 201

@app.route('/api/users/me', methods=['PATCH'])
def update_me():
    dto = UserUpdateDTO(**request.get_json())     # role/balance simply don't exist
    user = current_user()
    if dto.display_name is not None:
        user.display_name = dto.display_name
    db.session.commit()
    return jsonify(ok=True)

What changed: an input DTO with extra='forbid' is the only thing the client can populate; sensitive fields are assigned by the server. Injecting "role":"admin" now raises a validation error instead of being persisted.

Express + Mongoose (Node.js)

Vulnerable

const express = require('express');
const User = require('./models/User');   // fields: username, email, role, isVerified, balance
const app = express();
app.use(express.json());

app.post('/api/users', async (req, res) => {
    // The entire body is handed to the model constructor.
    const user = new User(req.body);      // { role: "admin" } is bound here
    await user.save();
    res.status(201).json(user);
});

app.patch('/api/users/me', async (req, res) => {
    const user = await User.findById(req.userId);
    Object.assign(user, req.body);        // merges ANY field: isVerified, balance...
    await user.save();
    res.json(user);
});

Why it's vulnerable: new User(req.body) and Object.assign(user, req.body) copy every key from the untrusted body onto the document, including role, isVerified, and balance.

Secure

const express = require('express');
const { z } = require('zod');
const User = require('./models/User');
const app = express();
app.use(express.json());

// Allow-list schemas: only these keys are accepted; unknown keys are stripped/rejected.
const CreateUser = z.object({
    username: z.string().min(1),
    email: z.string().email(),
    password: z.string().min(8),
}).strict();                              // .strict() -> unknown keys rejected

const UpdateUser = z.object({
    displayName: z.string().min(1).optional(),
}).strict();

app.post('/api/users', async (req, res) => {
    const parsed = CreateUser.safeParse(req.body);
    if (!parsed.success) return res.status(400).json({ error: 'invalid input' });

    const { username, email, password } = parsed.data;
    const user = new User({ username, email });
    await user.setPassword(password);
    user.role = 'user';                   // server-controlled fields set in code
    user.isVerified = false;
    user.balance = 0;
    await user.save();
    res.status(201).json({ id: user._id, username: user.username });
});

app.patch('/api/users/me', async (req, res) => {
    const parsed = UpdateUser.safeParse(req.body);
    if (!parsed.success) return res.status(400).json({ error: 'invalid input' });

    const user = await User.findById(req.userId);
    if (parsed.data.displayName !== undefined) user.displayName = parsed.data.displayName;
    await user.save();
    res.json({ id: user._id, displayName: user.displayName });
});

What changed: a zod .strict() schema is the input boundary, so only whitelisted keys survive. The model is constructed field-by-field from validated data—never from req.body directly.

Spring Boot + JPA (Java)

Vulnerable

// User is a JPA @Entity with: username, email, role, verified, balance
@RestController
class UserController {

    // Binding the request straight onto the @Entity: every matching field is set.
    @PostMapping("/api/users")
    public User create(@RequestBody User user) {   // {"role":"ADMIN"} is bound here
        return repository.save(user);
    }

    @PatchMapping("/api/users/me")
    public User update(@RequestBody User incoming, Principal principal) {
        User user = repository.findByUsername(principal.getName());
        user.setRole(incoming.getRole());          // whatever the client sent
        user.setVerified(incoming.isVerified());   // ...including sensitive fields
        user.setBalance(incoming.getBalance());
        return repository.save(user);
    }
}

Why it's vulnerable: @RequestBody User lets Jackson populate the entity from the JSON, so role, verified, and balance are all settable by the caller. The update copies them back verbatim.

Secure

// Input DTOs - only client-settable fields exist on these records.
public record UserCreateRequest(
        @NotBlank String username,
        @Email String email,
        @NotBlank String password) {}

public record UserUpdateRequest(String displayName) {}

@RestController
class UserController {

    @PostMapping("/api/users")
    public UserView create(@RequestBody @Valid UserCreateRequest req) {
        User user = new User();
        user.setUsername(req.username());
        user.setEmail(req.email());
        user.setPassword(encoder.encode(req.password()));
        user.setRole(Role.USER);            // server-controlled, never from request
        user.setVerified(false);
        user.setBalance(BigDecimal.ZERO);
        return UserView.from(repository.save(user));
    }

    @PatchMapping("/api/users/me")
    public UserView update(@RequestBody @Valid UserUpdateRequest req, Principal principal) {
        User user = repository.findByUsername(principal.getName());
        if (req.displayName() != null) user.setDisplayName(req.displayName());
        return UserView.from(repository.save(user));   // role/balance untouched
    }
}

// Defense in depth: block sensitive fields on the entity too.
@Entity
class User {
    @JsonProperty(access = JsonProperty.Access.READ_ONLY)
    private Role role;                      // never bound from client JSON
    @JsonIgnore
    private BigDecimal balance;             // never (de)serialized to/from clients
    // ...
}

What changed: request binding targets a DTO record that has no sensitive fields, and mapping to the entity is explicit. @JsonProperty(READ_ONLY) and @JsonIgnore add a second layer so the entity itself refuses to bind those fields.

What Changed, and Why

AspectVulnerableSecure
Binding targetPersistence/domain model (User, entity, document)Input DTO / schema with only client-settable fields
Field selectionAll keys in the body (**body, new Model(body), @RequestBody Entity)Explicit allow-list; unknown keys rejected (extra='forbid', .strict(), DTO record)
Sensitive fieldsClient can set role, isVerified, balanceServer assigns them from trusted state; marked read-only / ignored
Update handlerMerges the whole body onto the recordApplies only whitelisted, validated fields
Result of "role":"admin"Persisted → privilege escalationRejected or ignored → no effect

Next Steps