Rails strong parameters or Django ModelForm missing field exclusions allow attackers to set protected model attributes like is_admin or role.
TL;DR
permit!, Django __all__, Express req.body spread, Spring no-DTO, Laravel $guardedfilter(**kwargs) with user-controlled dict is CVE-2025-64459 (CVSS 9.1) — ORM operator injection via field namesstrict: true does NOT prevent mass assignment on declared schema fields like role — only strict: 'throw' catches unknown fieldsextra="allow" stores arbitrary request fields into the database via Pydantic dict spreadSave() updates ALL fields including zero-values — use Updates() with a dedicated DTO structORM-level mass assignment refers to the specific binding mechanism within each Object-Relational Mapper that enables field injection. Every major ORM has a built-in auto-binding feature designed for developer convenience: passing a parsed request body directly to a model update operation. When this convenience is used without an explicit field allowlist, every field in the request body — including privileged ones — reaches the database.
The canonical patterns vary by framework but share the same root cause: user-controlled data flowing directly into a model write operation without field-level filtering. Understanding the ORM-specific pattern is essential for both testing (to construct exploits) and remediation (to apply the correct framework-native fix).
| ORM | Vulnerable Pattern | Mechanism | Safe Pattern |
|---|---|---|---|
| Rails ActiveRecord | params.require(:user).permit! | update_attributes binds all keys | permit(:email, :name) explicit |
| Django DRF | Meta.fields = '__all__' | Serializer maps all model fields | Explicit fields = [...] list |
| Express + Mongoose | User.findByIdAndUpdate(id, req.body) | MongoDB $set all body keys | Destructure allowed keys only |
| Spring Boot | No DTO — bind entity directly | DataBinder maps all bean properties | Dedicated UserUpdateDto |
| Laravel Eloquent | $user->fill($request->all()) | fill() respects $fillable only if set | $fillable = ['email', 'name'] |
| FastAPI + Pydantic | model_config = ConfigDict(extra="allow") | Pydantic stores all extra fields | extra="forbid" in model_config |
| Sequelize (Node.js) | Model.update(req.body, {where}) | update() without fields option | fields: ['email', 'firstName'] |
| GORM (Go) | db.Save(&userFromBody) | Save() updates ALL struct fields | db.Model(&u).Updates(dto) |
Vulnerable patterns:
# Pattern 1 — permit! (permits everything)
def user_params
params.require(:user).permit!
end
# Pattern 2 — no permit() at all (Rails 3 style)
def update
@user.update_attributes(params[:user])
end
# Pattern 3 — update without strong_params
def update
@user.update(params[:user].to_unsafe_h)
endExploitation:
PATCH /users/42 HTTP/1.1
Content-Type: application/x-www-form-urlencoded
user[email]=x@y.com&user[admin]=1&user[role]=superadmin&user[is_admin]=trueSafe pattern:
def user_params
params.require(:user).permit(:email, :first_name, :last_name, :bio)
# admin, role, subscription_tier never reach the model
end
# Strict mode — raise on injection attempt instead of silently ignore
# config/application.rb
config.action_controller.action_on_unpermitted_parameters = :raiseBrakeman detects permit! and unfiltered parameter assignment statically:
brakeman --only-files app/controllers/ --run-all-checks
# Warns on: Mass Assignment, Unscoped Find, SQL InjectionVulnerable pattern:
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = '__all__'
# Exposes: is_superuser, is_staff, groups, user_permissions, passwordExploitation:
PATCH /api/users/me/ HTTP/1.1
Content-Type: application/json
{
"is_staff": true,
"is_superuser": true,
"is_active": true,
"groups": [1],
"user_permissions": [1, 2, 3]
}CVE-2025-64459 pattern — ORM filter kwargs injection:
# VULNERABLE — user controls ORM filter arguments
def list_users(request):
queryset = User.objects.filter(**request.GET.dict())
# GET /api/users?is_superuser=true → filter(is_superuser='true')
# GET /api/users?role__isnull=false → filter(role__isnull='false')Safe pattern:
class UserUpdateSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['email', 'first_name', 'last_name', 'bio']
read_only_fields = ['id', 'is_staff', 'is_superuser', 'groups',
'user_permissions', 'date_joined', 'role']
# Never pass request.GET.dict() to filter() directly
# Use explicit query parameters:
def list_users(request):
email = request.GET.get('email')
queryset = User.objects.all()
if email:
queryset = queryset.filter(email=email)Vulnerable patterns:
// Pattern 1 — full body to findByIdAndUpdate
app.patch('/api/users/me', async (req, res) => {
const user = await User.findByIdAndUpdate(req.user.id, req.body, {new: true});
res.json(user);
});
// Pattern 2 — Object.assign spread
app.patch('/api/users/me', async (req, res) => {
const user = await User.findById(req.user.id);
Object.assign(user, req.body); // copies all body keys to user object
await user.save();
res.json(user);
});
// Pattern 3 — spread operator
const update = {...user.toObject(), ...req.body};
await User.findByIdAndUpdate(id, update);Exploitation (NoSQL operator injection chain):
PATCH /api/users/me HTTP/1.1
Content-Type: application/json
{
"role": "admin",
"$set": {"is_admin": true},
"$unset": {"mfa_required": ""}
}With Mongoose, $set and $unset execute as MongoDB operators when the body is passed directly to findByIdAndUpdate.
Safe pattern:
app.patch('/api/users/me', async (req, res) => {
// Allowlist: only these fields may be updated
const ALLOWED = ['email', 'firstName', 'lastName', 'bio', 'avatarUrl'];
const update = Object.fromEntries(
Object.entries(req.body).filter(([key]) => ALLOWED.includes(key))
);
// Prevent NoSQL operator injection explicitly
const sanitized = Object.fromEntries(
Object.entries(update).filter(([key]) => !key.startsWith('$'))
);
const user = await User.findByIdAndUpdate(
req.user.id,
sanitized,
{ new: true, runValidators: true }
);
res.json(user);
});Vulnerable pattern:
// VULNERABLE — binds all request parameters to User entity
@PostMapping("/users")
public ResponseEntity<User> create(User user) {
// Spring DataBinder maps role, enabled, accountNonLocked automatically
return ResponseEntity.ok(userRepository.save(user));
}Exploitation:
POST /users HTTP/1.1
Content-Type: application/json
{
"username": "attacker",
"email": "attacker@example.com",
"role": "ROLE_ADMIN",
"authorities": [{"authority": "ROLE_ADMIN"}],
"accountNonLocked": true,
"enabled": true
}Safe pattern:
// DTO — no privileged fields declared
public class UserUpdateDto {
@NotBlank @Email private String email;
@Size(max = 50) private String firstName;
@Size(max = 50) private String lastName;
// role, authorities, enabled — not present
}
// Controller uses DTO
@PatchMapping("/users/me")
public ResponseEntity<UserResponse> updateMe(
@Valid @RequestBody UserUpdateDto dto,
@AuthenticationPrincipal UserDetails principal) {
return ResponseEntity.ok(userService.update(principal.getUsername(), dto));
}
// @InitBinder as additional defense
@InitBinder
public void initBinder(WebDataBinder binder) {
binder.setDisallowedFields("role", "authorities", "enabled",
"accountNonLocked", "accountNonExpired");
}Vulnerable patterns:
// Pattern 1 — fill() without $fillable
class User extends Model {
// No $fillable — every column is mass-assignable
}
$user->fill($request->all());
// Pattern 2 — unguard()
Model::unguard(); // Disables ALL mass assignment protection globally
$user = User::create($request->all());
// Pattern 3 — guarded denylist — fails when new sensitive fields added
protected $guarded = ['admin', 'role'];
// New field 'subscription_tier' added → not in $guarded → writableSafe pattern:
class User extends Model {
// Allowlist — new fields are blocked by default
protected $fillable = ['name', 'email', 'bio', 'avatar'];
// admin, role, subscription_tier blocked automatically
}
// Controller — use fillable
$user->fill($request->only(['name', 'email', 'bio']));
$user->save();Vulnerable pattern:
class UserUpdate(BaseModel):
model_config = ConfigDict(extra="allow")
email: str | None = None
@router.patch("/users/me")
async def update_me(body: UserUpdate, db: Session = Depends(get_db),
user: User = Depends(get_current_user)):
update_data = body.model_dump() # includes is_superuser: true if sent
db.query(User).filter(User.id == user.id).update(update_data)Safe pattern:
class UserUpdate(BaseModel):
model_config = ConfigDict(extra="forbid") # 422 for unknown fields
email: EmailStr | None = None
first_name: str | None = None
last_name: str | None = None
@router.patch("/users/me")
async def update_me(body: UserUpdate, db: Session = Depends(get_db),
user: User = Depends(get_current_user)):
update_data = body.model_dump(exclude_unset=True)
# update_data can only contain email, first_name, last_name
db.query(User).filter(User.id == user.id).update(update_data)Vulnerable pattern:
// VULNERABLE — Save() updates ALL struct fields including zero values
func UpdateUser(c *gin.Context) {
var user models.User
c.ShouldBindJSON(&user) // Binds FULL body into User struct
db.Save(&user) // Writes every field including Role association
}Safe pattern:
// UserUpdateInput — only user-writable fields
type UserUpdateInput struct {
Email *string `json:"email,omitempty"`
FirstName *string `json:"first_name,omitempty"`
LastName *string `json:"last_name,omitempty"`
}
func UpdateMe(c *gin.Context) {
var input UserUpdateInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
// Updates() only modifies non-zero fields; Omit() excludes associations
db.Model(¤tUser).
Omit("Role", "Permissions", "Subscription").
Updates(input)
c.JSON(200, currentUser)
}CVE-2024-29133 — Apache Roller (CVSS 8.1, 2024)
Apache Roller user profile update handler did not filter the role parameter from form POST body. Any authenticated user could set role=ADMIN via application/x-www-form-urlencoded to become an administrator. Direct ORM binding without allowlist filtering. CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N.
CVE-2025-23085 — Node.js Express + NoSQL Operator (CVSS 7.3, 2025)
Express API passing req.body directly to Mongoose queries allowed MongoDB operators ($ne, $set) as JSON keys, combining mass assignment with NoSQL injection. The $set: {role: "admin"} payload bypassed application-level field filtering that only checked top-level key names.
CVE-2024-49504 — PostgREST Role Smuggling (CVSS 7.5, 2024)
PostgREST's auto-generated REST API from PostgreSQL tables accepted role field in PATCH requests without RLS enforcement at the HTTP layer. JSON field injection bypassed Row Level Security by writing to the role column before the RLS policy was checked.
cd /path/to/rails-app
brakeman --run-all-checks --format json | \
jq '.warnings[] | select(.warning_type == "Mass Assignment")'# Detect Django __all__ pattern
semgrep --pattern 'class $META: fields = "__all__"' --lang python .
# Detect Express req.body spread
semgrep --pattern 'findByIdAndUpdate($ID, req.body)' --lang javascript .
# Detect FastAPI extra=allow
semgrep --pattern 'ConfigDict(extra="allow")' --lang python .BreachVex combines runtime mass assignment probing with static pattern detection across all six ORM patterns during its automated API security analysis.
ORM-level mass assignment refers to the specific mechanism within each Object-Relational Mapper that enables or prevents field injection. Each ORM has a canonical vulnerable pattern: Rails permit!/update_attributes without permit, Django ModelSerializer __all__, Express findByIdAndUpdate(id, req.body), Spring Boot entity binding without DTO, Laravel without $fillable, FastAPI with extra=allow. Understanding the ORM-specific pattern is essential for both exploitation and remediation.
attr_accessible was the Rails 2/3 mass assignment protection — a model-level allowlist. It was deprecated in Rails 4 and replaced by strong_parameters (controller-level allowlist via permit). strong_parameters is more granular because different controllers can permit different fields for the same model. Using params.require(:user).permit! or forgetting permit() re-enables mass assignment in Rails 4+.
Django REST Framework ModelSerializer with Meta.fields = '__all__' automatically includes every field on the Django model in the serializer, including is_superuser, is_staff, groups, and user_permissions. These are writable by default in a ModelSerializer unless explicitly listed in read_only_fields. Any PATCH endpoint using this serializer allows privilege escalation.
Mongoose schema's strict option controls whether values for fields not in the schema are saved. strict: true (default) silently ignores undeclared fields. strict: 'throw' raises an error for undeclared fields. However, strict mode protects against unknown field names but NOT against known field names like role that are declared in the schema but should not be user-writable. The real protection is allowlist filtering before findByIdAndUpdate.
$fillable is an allowlist: only listed fields can be mass-assigned. $guarded is a denylist: listed fields cannot be mass-assigned, all others can. $fillable is always safer — a new sensitive field added to the model is blocked by default. $guarded fails when developers add a new sensitive field without updating the guard list. Model::unguard() disables all mass assignment protection.
Spring Boot's DataBinder automatically maps request parameters to Java bean properties. Without setAllowedFields() or setDisallowedFields() configured in @InitBinder, or without using a dedicated DTO class, the DataBinder binds every property that matches a setter method — including role, authorities, enabled, accountNonLocked. The safest pattern is a dedicated UserUpdateDto with no privileged fields declared.
Pydantic BaseModel with extra='allow' accepts and stores any extra fields sent in the request body. When the model.dict() result is then spread into a database update (db.query(User).filter(...).update(**schema.dict())), every extra field including is_superuser: true is written to the database. Fix: extra='forbid' causes Pydantic to return 422 for any undeclared field.
CVE-2024-29133 (Apache Roller, CVSS 8.1) — form-based mass assignment via role=ADMIN. CVE-2025-64459 (Django, CVSS 9.1) — ORM filter() with kwargs from request. CVE-2025-23085 (Node.js Express + NoSQL, CVSS 7.3) — req.body spread enabling $ne operator injection. CVE-2024-52034 (Strapi, CVSS 8.1) — ORM model save without field filtering.
GORM's Save() method updates all fields of the passed struct, including zero values. If a user-controlled struct (decoded from request body) is passed directly to Save(), every field — including role, isAdmin, subscriptionTier — is updated. Use Updates() (updates non-zero fields only) with a dedicated DTO struct, and use Omit() to exclude sensitive fields like associations.
Sequelize's Model.update(values, {where}) and Model.bulkCreate(records) accept all model fields by default. Without a fields option restricting which columns may be updated, an attacker can include role, isAdmin, or admin in the values object. Fix: use the fields: ['email', 'firstName'] option in Sequelize update/create calls to restrict to the allowed columns.