The profile form has three fields: name, avatar and timezone. The database row has twelve, including role, emailVerified and orgId. If the handler passes the request body straight to the ORM, the form is irrelevant. Anyone who adds "role": "admin" to the JSON gets to write it.
That is mass assignment, tracked by MITRE as CWE-915 and folded into OWASP API Security's API3:2023 Broken Object Property Level Authorization. It is one of the shortest routes from "signed-up user" to "administrator", and it is easy to write in every modern framework because binding a whole object is the convenient default.
Why is mass assignment dangerous?
The attacker does not need to break anything. They send a field the server accepts. The dangerous fields are usually one of these:
- Privilege:
role,isAdmin,permissions,plan. - Ownership and tenancy:
userId,ownerId,orgId,tenantId. Overwriting these moves a record into another account or another tenant. - State the server should control:
emailVerified,balance,status,approvedAt,price.
It also ages badly. The handler may be safe the day it ships, then someone adds isAdmin to the model six months later and every bind-the-body endpoint quietly starts accepting it. Django's own documentation warns about exactly this, saying that including all fields automatically "has led to serious exploits on major websites (e.g. GitHub)" (Django ModelForm docs).
Mass assignment examples by framework
Express with Prisma
// Vulnerable: the body becomes the update
app.patch('/api/me', requireAuth, async (req, res) => {
const user = await prisma.user.update({
where: { id: req.user.id },
data: req.body, // {"role": "ADMIN", "orgId": "..."} is accepted
});
res.json(user);
});The fix is to name the writable fields. A schema validator does this and checks types at the same time:
import { z } from 'zod';
const UpdateMe = z.object({
name: z.string().max(100).optional(),
avatarUrl: z.string().url().optional(),
timezone: z.string().max(64).optional(),
}).strict(); // unknown keys are an error, not silently ignored
app.patch('/api/me', requireAuth, async (req, res) => {
const data = UpdateMe.parse(req.body);
const user = await prisma.user.update({ where: { id: req.user.id }, data });
res.json(user);
});Object.assign(user, req.body) followed by save() and spreading ...req.body into a create call are the same bug in other clothes.
NestJS
A DTO only protects you if the validation pipe strips or rejects what the DTO does not declare. With whitelist: true, NestJS removes properties that have no validation decorator, and forbidNonWhitelisted: true turns them into an error instead (NestJS validation docs).
// main.ts
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }));
// update-profile.dto.ts
export class UpdateProfileDto {
@IsOptional() @IsString() @MaxLength(100) name?: string;
@IsOptional() @IsUrl() avatarUrl?: string;
}Without the pipe options, a role property in the body survives into the DTO instance even though the class never declares it, and this.repo.update(id, dto) writes it.
Django
ModelForm and Django REST Framework serializers both decide fields from a list:
# Vulnerable: every model field, including is_staff, is writable
class ProfileForm(forms.ModelForm):
class Meta:
model = User
fields = "__all__"
# Fixed: name the fields
class ProfileForm(forms.ModelForm):
class Meta:
model = User
fields = ["first_name", "last_name", "timezone"]The same applies to a serializer's fields, and to hand-written views that do User.objects.filter(pk=pk).update(**request.data) or loop over the body with setattr. Prefer fields over exclude: an exclude list fails open when the model grows.
Rails-style params
Rails made the allowlist mandatory. Passing unpermitted parameters to a model's mass assignment raises ActiveModel::ForbiddenAttributesError, and you declare the writable keys with permit (or expect in recent versions), as the Action Controller guide shows:
def profile_params
params.require(:user).permit(:name, :timezone)
endThe hole reopens when someone calls permit!, which permits everything. Other frameworks copied the idea under different names: Laravel's $fillable, for example. If your stack has no equivalent, build one.
Spring
Spring MVC binds request parameters onto a @ModelAttribute command object by walking any property the request names. The Spring reference recommends using an object tailored for web binding, or constructor binding only, and setting allowedFields patterns if you keep property binding (Spring MVC @ModelAttribute docs).
// Vulnerable: binds onto the JPA entity
@PostMapping("/profile")
public String save(@ModelAttribute User user) { repo.save(user); return "ok"; }
// Fixed: a binding record with only the editable fields
public record ProfileForm(String name, String timezone) {}
@PostMapping("/profile")
public String save(@ModelAttribute ProfileForm form, Principal p) {
User u = repo.findByUsername(p.getName()).orElseThrow();
u.setName(form.name());
u.setTimezone(form.timezone());
repo.save(u);
return "ok";
}@RequestBody User user with Jackson has the same exposure for JSON: Jackson sets every property the JSON names that the class can accept. Deserialize into a request record instead of the entity.
The fix, in one rule
Never bind the request into the persistence model. Bind it into a request-shaped type that contains only what this endpoint lets this caller change, then copy those fields across explicitly. In practice:
- One input type per operation.
CreateUserRequestandUpdateMyProfileRequestare different types, because an admin endpoint may setroleand a self-service endpoint never should. - Reject unknown fields rather than silently dropping them. A 400 tells you when a client (or attacker) is sending something unexpected.
- Set server-owned fields on the server.
ownerIdcomes from the session, never from the body, even on create. - Keep output types separate too. Returning the entity leaks the same privileged fields you just protected (the other half of API3).
What Vulkro checks
Vulkro reports body-to-model binds such as Object.assign(user, req.body), Model(**request.json) and setattr loops over the body, and names the bound model and the missing allowlist. The rule page is API3:2023 Broken Object Property Level Authorization. Language-specific rules include JAVA-MASSASSIGN-001 for Java and PHP-MASS-ASSIGN-001 for a whole request passed to an Eloquent write, and the Spring data binding rule flags @ModelAttribute binding with no allowlist. Because Vulkro assigns each endpoint an auth tier, a bind on an anonymous or ordinary-user route reads differently from one behind an admin gate, which is the question that decides how urgent the fix is. vulkro fix prints a remedy for mass assignment findings in JavaScript, TypeScript, Python, Go and Java.
FAQ
What is a mass assignment vulnerability?
A server takes an object from the request and copies all of its properties into a model, so a client can set fields the interface never offered, such as role, isAdmin or ownerId.
Does a TypeScript type prevent mass assignment?
No. TypeScript types disappear at runtime, so req.body as UpdateProfileDto checks nothing. You need runtime validation that strips or rejects undeclared keys, such as a schema with strict mode or NestJS's ValidationPipe with whitelist.
Is mass assignment the same as IDOR?
They are related but different. IDOR lets you act on someone else's record by changing an identifier. Mass assignment lets you write fields you should not control on a record you can reach. Overwriting ownerId through mass assignment can create an IDOR-style result.
Start a 14-day trial at /start/. Related reading: IDOR prevention in Express and NestJS and OWASP API Security Top 10 from source code.