Mass assignment ORM (CWE-915) : strong parameters Rails manquants ou exclusions Django ModelForm absentes permettent de définir is_admin ou role directement.
TL;DR
permit!, Django __all__, Express spread req.body, Spring sans DTO, Laravel $guardedfilter(**kwargs) avec dict contrôlé par l'utilisateur est CVE-2025-64459 (CVSS 9.1) — injection d'opérateur ORM via noms de champsstrict: true ne prévient PAS le mass assignment sur les champs de schéma déclarés comme role — uniquement strict: 'throw' pour les champs inconnusextra="allow" stocke les champs de requête arbitraires en base via l'étalement du dict PydanticSave() met à jour TOUS les champs y compris les valeurs zéro — utiliser Updates() avec une struct DTO dédiéeLe mass assignment au niveau ORM fait référence au mécanisme de liaison spécifique à chaque Object-Relational Mapper qui active l'injection de champs. Chaque ORM majeur a une fonctionnalité de liaison automatique intégrée conçue pour la commodité du développeur : passer un corps de requête analysé directement à une opération d'écriture de modèle. Quand cette commodité est utilisée sans liste blanche explicite de champs, chaque champ du corps de la requête — y compris les privilégiés — atteint la base de données.
| ORM | Pattern vulnérable | Mécanisme | Pattern sécurisé |
|---|---|---|---|
| Rails ActiveRecord | params.require(:user).permit! | update_attributes lie toutes les clés | permit(:email, :name) explicite |
| Django DRF | Meta.fields = '__all__' | Sérialiseur mappe tous les champs du modèle | Liste fields = [...] explicite |
| Express + Mongoose | User.findByIdAndUpdate(id, req.body) | MongoDB $set toutes les clés du corps | Déstructurer uniquement les clés autorisées |
| Spring Boot | Pas de DTO | DataBinder mappe toutes les propriétés du bean | Classe UserUpdateDto dédiée |
| Laravel Eloquent | $user->fill($request->all()) | fill() respecte $fillable seulement si défini | $fillable = ['email', 'name'] |
| FastAPI + Pydantic | extra="allow" dans model_config | Pydantic stocke tous les champs supplémentaires | extra="forbid" dans model_config |
| Sequelize (Node.js) | Model.update(req.body, {where}) | update() sans option fields | fields: ['email', 'firstName'] |
| GORM (Go) | db.Save(&userFromBody) | Save() met à jour TOUS les champs de la struct | db.Model(&u).Updates(dto) |
Patterns vulnérables :
# Pattern 1 — permit! (permet tout)
def user_params
params.require(:user).permit!
end
# Pattern 2 — pas de permit() du tout (style Rails 3)
def update
@user.update_attributes(params[:user])
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]=truePattern sécurisé :
def user_params
params.require(:user).permit(:email, :first_name, :last_name, :bio)
end
# Mode strict — lever une exception au lieu d'ignorer silencieusement
config.action_controller.action_on_unpermitted_parameters = :raisePattern vulnérable :
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = '__all__'
# Expose : is_superuser, is_staff, groups, user_permissions, passwordPattern CVE-2025-64459 — injection kwargs ORM :
# VULNÉRABLE — l'utilisateur contrôle les arguments de filtre ORM
def list_users(request):
queryset = User.objects.filter(**request.GET.dict())
# GET /api/users?is_superuser=true → filter(is_superuser='true')Pattern sécurisé :
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']Patterns vulnérables :
// Pattern 1 — corps entier vers findByIdAndUpdate
const user = await User.findByIdAndUpdate(req.user.id, req.body, {new: true});
// Pattern 2 — spread Object.assign
Object.assign(user, req.body);
await user.save();Exploitation (chaîne injection opérateur NoSQL) :
PATCH /api/users/me HTTP/1.1
Content-Type: application/json
{
"role": "admin",
"$set": {"is_admin": true},
"$unset": {"mfa_required": ""}
}Pattern sécurisé :
app.patch('/api/users/me', async (req, res) => {
const ALLOWED = ['email', 'firstName', 'lastName', 'bio'];
const update = Object.fromEntries(
Object.entries(req.body).filter(([key]) => ALLOWED.includes(key))
);
const user = await User.findByIdAndUpdate(req.user.id, update,
{ new: true, runValidators: true });
res.json(user);
});Pattern sécurisé :
// DTO — pas de champs privilégiés déclarés
public class UserUpdateDto {
@NotBlank @Email private String email;
@Size(max = 50) private String firstName;
@Size(max = 50) private String lastName;
}
@PatchMapping("/users/me")
public ResponseEntity<UserResponse> updateMe(
@Valid @RequestBody UserUpdateDto dto,
@AuthenticationPrincipal UserDetails principal) {
return ResponseEntity.ok(userService.update(principal.getUsername(), dto));
}
@InitBinder
public void initBinder(WebDataBinder binder) {
binder.setDisallowedFields("role", "authorities", "enabled",
"accountNonLocked", "accountNonExpired");
}Pattern sécurisé :
# extra="forbid" retourne 422 pour les champs inconnus
class UserUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
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)
db.query(User).filter(User.id == user.id).update(update_data)Pattern vulnérable :
// VULNÉRABLE — Save() met à jour TOUS les champs de la struct y compris les valeurs zéro
func UpdateUser(c *gin.Context) {
var user models.User
c.ShouldBindJSON(&user) // Lie le corps COMPLET dans la struct User
db.Save(&user) // Écrit chaque champ y compris l'association Role
}Pattern sécurisé :
// UserUpdateInput — uniquement les champs écribles par l'utilisateur
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() modifie uniquement les champs non-zéro ; Omit() exclut les associations
db.Model(¤tUser).
Omit("Role", "Permissions", "Subscription").
Updates(input)
c.JSON(200, currentUser)
}Pattern vulnérable :
// VULNÉRABLE — toutes les colonnes du modèle acceptées sans restriction
await User.update(req.body, { where: { id: req.user.id } });
// VULNÉRABLE — bulkCreate sans option fields
await User.bulkCreate(req.body.users);Exploitation :
PATCH /api/users/me HTTP/1.1
Content-Type: application/json
{
"email": "attaquant@example.com",
"role": "admin",
"isAdmin": true,
"subscriptionTier": "enterprise"
}Pattern sécurisé :
// Restreindre les colonnes via l'option fields
await User.update(req.body, {
where: { id: req.user.id },
fields: ['email', 'firstName', 'lastName', 'bio']
// role, isAdmin, subscriptionTier bloqués automatiquement
});CVE-2024-29133 — Apache Roller (CVSS 8.1, 2024)
Le gestionnaire de mise à jour du profil utilisateur d'Apache Roller ne filtrait pas le paramètre role du corps POST de formulaire. N'importe quel utilisateur authentifié pouvait se promouvoir administrateur. 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 + Opérateur NoSQL (CVSS 7.3, 2025)
L'API Express passant req.body directement aux requêtes Mongoose permettait aux opérateurs MongoDB ($ne, $set) comme clés JSON de combiner mass assignment et injection NoSQL. La charge $set: {role: "admin"} contournait le filtrage de champs au niveau applicatif qui ne vérifiait que les noms de clés de niveau supérieur.
CVE-2024-49504 — Smuggling de rôle PostgREST (CVSS 7.5, 2024)
L'API REST auto-générée de PostgREST depuis les tables PostgreSQL acceptait le champ role dans les requêtes PATCH sans enforcement RLS au niveau HTTP. L'injection de champ JSON contournait la Row Level Security en écrivant sur la colonne role avant que la politique RLS soit vérifiée.
brakeman --run-all-checks --format json | \
jq '.warnings[] | select(.warning_type == "Mass Assignment")'# Détecter le pattern Django __all__
semgrep --pattern 'class $META: fields = "__all__"' --lang python .
# Détecter le spread req.body Express
semgrep --pattern 'findByIdAndUpdate($ID, req.body)' --lang javascript .
# Détecter FastAPI extra=allow
semgrep --pattern 'ConfigDict(extra="allow")' --lang python .Le mass assignment au niveau ORM fait référence au mécanisme spécifique de chaque ORM qui active ou prévient l'injection de champs. Chaque ORM a un pattern vulnérable canonique : permit!/update_attributes sans permit pour Rails, Meta.fields='__all__' pour Django, findByIdAndUpdate(id, req.body) pour Express, liaison d'entité sans DTO pour Spring Boot, sans $fillable pour Laravel, extra='allow' pour FastAPI.
attr_accessible était la protection Rails 2/3 — une liste blanche au niveau du modèle. Dépréciée dans Rails 4 et remplacée par strong_parameters (liste blanche au niveau contrôleur via permit). strong_parameters est plus granulaire car différents contrôleurs peuvent permettre différents champs pour le même modèle. Utiliser params.require(:user).permit! réactive le mass assignment.
Le ModelSerializer de Django REST Framework avec Meta.fields = '__all__' inclut automatiquement chaque champ du modèle Django dans le sérialiseur, y compris is_superuser, is_staff, groups et user_permissions. Ces champs sont écribles par défaut dans un ModelSerializer sauf si explicitement listés dans read_only_fields. Tout endpoint PATCH utilisant ce sérialiseur permet l'escalade de privilèges.
L'option strict du schéma Mongoose contrôle si les valeurs pour les champs non déclarés dans le schéma sont sauvegardées. strict: true (défaut) ignore silencieusement les champs non déclarés. strict: 'throw' lève une erreur. Cependant, le mode strict ne protège PAS contre les noms de champs connus comme role qui sont déclarés dans le schéma mais ne devraient pas être écrits par l'utilisateur. La vraie protection est le filtrage par liste blanche avant findByIdAndUpdate.
$fillable est une liste blanche : seuls les champs listés peuvent être assignés en masse. $guarded est une liste noire : les champs listés ne peuvent pas être assignés en masse, tous les autres peuvent. $fillable est toujours plus sûr — un nouveau champ sensible ajouté au modèle est bloqué par défaut. $guarded échoue quand des développeurs ajoutent un nouveau champ sensible sans mettre à jour la liste.
Pydantic BaseModel avec extra='allow' accepte et stocke tous les champs supplémentaires envoyés dans le corps de la requête. Quand le résultat de model.dict() est ensuite étalé dans une mise à jour de base de données, chaque champ supplémentaire incluant is_superuser: true est écrit en base. Correction : extra='forbid' fait retourner 422 à Pydantic pour tout champ non déclaré.
CVE-2024-29133 (Apache Roller, CVSS 8.1) — mass assignment basé sur formulaire via role=ADMIN. CVE-2025-64459 (Django, CVSS 9.1) — ORM filter() avec kwargs de la requête. CVE-2025-23085 (Node.js Express + NoSQL, CVSS 7.3) — spread req.body activant l'injection d'opérateur $ne. CVE-2024-52034 (Strapi, CVSS 8.1) — sauvegarde de modèle ORM sans filtrage.
La méthode Save() de GORM met à jour tous les champs de la struct passée, y compris les valeurs zéro. Si une struct contrôlée par l'utilisateur (décodée depuis le corps de la requête) est passée directement à Save(), chaque champ — y compris role, isAdmin, subscriptionTier — est mis à jour. Utiliser Updates() (met à jour uniquement les champs non-zéro) avec une struct DTO dédiée, et Omit() pour exclure les champs sensibles.
Les règles Semgrep peuvent détecter les patterns ORM vulnérables : class $META: fields = "__all__" pour Django, findByIdAndUpdate($ID, req.body) pour Express/Mongoose, ConfigDict(extra="allow") pour FastAPI. L'outil Brakeman pour Rails détecte spécifiquement permit! et les appels update_attributes non filtrés.