CWE-915: Improperly Controlled Modification of Dynamically-Determined Object Attributes - PHP / Laravel
Overview
Mass assignment vulnerabilities in Laravel occur when Model::create($request->all()) or $model->update($request->all()) is called, allowing request parameters to overwrite any Eloquent model attribute - including security-critical ones like is_admin, role, email_verified_at, or subscription_tier. Laravel provides $fillable (allowlist) and $guarded (denylist) properties on Eloquent models to control which attributes can be mass-assigned, but they only work if correctly configured.
Models with protected $guarded = [] have no protection. Models missing both $fillable and $guarded throw a MassAssignmentException on any mass-assigned attribute, and that check is not gated by environment - it fires in production the same as it does locally. It stops firing once Model::unguard() disables the protection, which is what test or bootstrap code left in production does.
Primary Defence: Define $fillable on every Eloquent model with an explicit allowlist of user-settable fields. Use $request->only(['field1', 'field2']) or $request->validated() (from a Form Request) instead of $request->all(). Never list security-critical fields in $fillable.
Common Vulnerable Patterns
Model::create($request->all())
<?php
// VULNERABLE - attacker can set is_admin, role, email_verified_at, etc.
class UserController extends Controller
{
public function store(Request $request): RedirectResponse
{
// Attack: POST /users with { "name": "Alice", "is_admin": 1, "role": "admin" }
$user = User::create($request->all()); // ALL request fields passed to the model
return redirect()->route('users.show', $user);
}
}
Why this is vulnerable:
$request->all()returns every field in the request body, including those the developer never intended to accept. Ifis_adminorroleare in the database and not protected, they will be set to the attacker's value.
$guarded = [] Disables All Protection
<?php
// VULNERABLE - empty $guarded means no protection
class User extends Model
{
protected $guarded = []; // Equivalent to Model::unguard() for this model
// All attributes are now mass-assignable, including is_admin, role, etc.
}
Why this is vulnerable:
$guarded = []is an empty denylist, so nothing is blocked: every attribute the controller passes is mass-assignable.
$model->update($request->all()) on Existing Records
<?php
// VULNERABLE - update can also overwrite protected fields if $guarded = []
class ProfileController extends Controller
{
public function update(Request $request, int $userId): RedirectResponse
{
$user = User::findOrFail($userId);
$user->update($request->all()); // Attack: { "name": "Alice", "role": "admin" }
return redirect()->back();
}
}
Why this is vulnerable:
- The
update()method respects$fillable/$guarded, but if the model has$guarded = [], the same attack applies to updates as to creates.
Nested Arrays Declared but Not Described
<?php
// VULNERABLE - 'settings' is validated as an array, but its keys are not
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'settings' => ['array'],
]);
// $fillable is ['name', 'settings'], $casts is ['settings' => 'array']
$user = User::create($validated);
// Attack: { "name": "Alice",
// "settings": { "theme": "dark", "is_admin": true, "role": "administrator" } }
// -> the top-level is_admin is stripped, and settings is stored whole:
// {"theme":"dark","is_admin":true,"role":"administrator"}
Why this is vulnerable: validated() returns the fields your rules name, and a rule names a top-level field unless you write one for the keys beneath it. 'settings' => ['array'] asserts that settings is an array and says nothing about what may be in it, so the whole hash - every key the attacker chose to send - is returned and mass-assigned into the JSON column. Both allowlists on this page did their job at the top level: $fillable kept the request's own is_admin out, and validation stripped it too. Neither looks inside settings, so any code that later reads $user->settings['is_admin'] is reading attacker-supplied data. A settings, preferences, or metadata hash is the most likely place for this to matter, because a bag of keys is what it is for.
Secure Patterns
$fillable Allowlist on the Model
<?php
// SECURE - only listed fields can be mass-assigned
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
class User extends Authenticatable
{
// Explicit allowlist - ONLY these fields can be mass-assigned
protected $fillable = [
'name',
'email',
'password',
'bio',
'website',
'settings',
];
// 'is_admin', 'role', 'email_verified_at', 'subscription_tier'
// are intentionally NOT listed - they cannot be mass-assigned
protected function casts(): array
{
// A fillable array/json attribute is an allowlist entry for the whole
// hash - $fillable cannot see inside it. Its keys are allowlisted by
// the validation rules instead; see the nested-array pattern above.
return ['settings' => 'array'];
}
}
Why this works:
$fillableis an allowlist. WhenUser::create($data)is called, Laravel'sfill()method strips any key from$datathat is not in$fillablebefore setting the attribute. An attacker who sendsis_admin=1will have that field silently ignored.- It is an allowlist of attributes, so an attribute holding structured data is admitted or refused whole.
settingsis fillable, and everything inside it is therefore fillable too - which is why the keys of anarray-cast column have to be allowlisted by validation rules rather than here.
Validated Input in the Controller
<?php
// SECURE - only explicitly named fields are passed to the model
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class UserController extends Controller
{
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'unique:users'],
'password' => ['required', 'string', 'min:12', 'confirmed'],
]);
// Mass assignment covers only the fillable attributes...
$user = new User($validated);
// ...so the non-fillable ones are set directly, outside mass assignment
$user->role = 'user';
$user->is_admin = false;
$user->save();
return response()->json($user, 201);
}
}
Why this works:
$request->validate()returns only the fields declared in the rules array; any other top-level field in the request body is absent from$validated, sonew User($validated)has nothing but the three declared attributes to assign. All three are scalars here - see the nested-array pattern above for what changes when a rule names an array.roleandis_adminare set by direct property assignment, which is not mass assignment and so is not filtered by$fillable. Passing them throughUser::create([...$validated, 'role' => 'user'])instead would not work: they are deliberately absent from$fillable, so Eloquent drops them and the user is saved with whatever the column defaults to. UnlessModel::preventSilentlyDiscardingAttributes()is enabled, that discard is silent - the usual way a correct$fillablelist quietly breaks the server-set defaults meant to sit alongside it.- Use
$request->only(['name', 'email'])where there is no validation to lean on. It is an allowlist in the same sense;$request->except()is not.
Form Request for Reusable Validation
<?php
// app/Http/Requests/UpdateProfileRequest.php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateProfileRequest extends FormRequest
{
public function authorize(): bool
{
// Ensure the user can only update their own profile.
// route('user') is the bound User model, not its id - comparing it to
// an int would cast the object and match every caller against 1.
return $this->user()->is($this->route('user'));
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'bio' => ['nullable', 'string', 'max:500'],
'website' => ['nullable', 'url', 'max:255'],
// 'role', 'is_admin' intentionally absent
// Nested keys need their own rules; 'settings' => ['array'] alone
// would return the whole hash. 'array:theme,locale' is the
// alternative and rejects the request on an unknown key.
'settings' => ['sometimes', 'array'],
'settings.theme' => ['required_with:settings', 'in:light,dark'],
'settings.locale' => ['sometimes', 'string', 'size:5'],
];
}
}
// app/Http/Controllers/ProfileController.php
class ProfileController extends Controller
{
public function update(UpdateProfileRequest $request, User $user): RedirectResponse
{
// validated() contains only fields declared in rules()
$user->update($request->validated());
return redirect()->route('profile');
}
}
Why this works:
- Laravel Form Requests centralize validation and authorization for a specific action.
$request->validated()contains only the keys the declared rules name, at every level they name them. Any top-level field not inrules()- includingis_admin- is absent, and becausesettingshas rules for its own keys,settings.roleis dropped rather than carried through inside the array. - Every attribute in
rules()here is also in the model's$fillable, which is what makesupdate($request->validated())safe to call directly. Where an action needs to set an attribute the model deliberately guards, assign it explicitly as the create example does rather than widening$fillableto make the mass assignment work.
Testing
- Normal input: create and update models with valid user-editable fields and confirm expected persistence.
- Boundary input: submit unknown fields, nested arrays, empty strings, and nullable fields to verify validation behavior.
- Malicious input: include
is_admin,role,email_verified_at, or tenant ownership fields; confirm Eloquent does not persist them from request data.
Common Pitfalls
- Defining
$fillableon theUsermodel but leaving a separate admin controller or artisan command that calls$user->forceFill($request->all())->save()-forceFill()deliberately bypasses$fillable/$guardedmass-assignment protection, so any code path using it is unprotected regardless of what the model declares. - Writing
'settings' => ['array']and treating the array as validated - the rule checks the type and permits every key inside.validated()skips the whole-array entry only when at least one rule beneath it exists, so a singlesettings.themerule changes the result for the entire hash and its absence quietly passes everything through. Check nested rules exist wherever a fillable attribute is cast toarrayorjson. - Using
$request->validate([...])with a rules array that omitsis_admin, but then merging in extra fields witharray_merge($validated, $request->except('some_other_field'))-except()returns everything except the named field, which is the opposite of an allowlist and lets any other unvalidated field (includingis_admin) back in. - Adding
$fillableto the model used by the public-facing create-user endpoint, while an internal or legacy endpoint still targets a different model class (or the same table via raw EloquentDB::table()queries) that has no such restriction - the allowlist is a model-level control and doesn't apply to query builder calls that bypass Eloquent entirely. - Removing
protected $guarded = []and adding$fillable, but leaving a test bootstrap or seeder file that callsModel::unguard()globally and is accidentally included in a production service provider -unguard()disables mass-assignment protection for every model untilreguard()is called, undoing the per-model$fillablefix.