Skip to content

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. If is_admin or role are 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:

  • $fillable is an allowlist. When User::create($data) is called, Laravel's fill() method strips any key from $data that is not in $fillable before setting the attribute. An attacker who sends is_admin=1 will have that field silently ignored.
  • It is an allowlist of attributes, so an attribute holding structured data is admitted or refused whole. settings is fillable, and everything inside it is therefore fillable too - which is why the keys of an array-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, so new 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.
  • role and is_admin are set by direct property assignment, which is not mass assignment and so is not filtered by $fillable. Passing them through User::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. Unless Model::preventSilentlyDiscardingAttributes() is enabled, that discard is silent - the usual way a correct $fillable list 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 in rules() - including is_admin - is absent, and because settings has rules for its own keys, settings.role is dropped rather than carried through inside the array.
  • Every attribute in rules() here is also in the model's $fillable, which is what makes update($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 $fillable to 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 $fillable on the User model but leaving a separate admin controller or artisan command that calls $user->forceFill($request->all())->save() - forceFill() deliberately bypasses $fillable/$guarded mass-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 single settings.theme rule changes the result for the entire hash and its absence quietly passes everything through. Check nested rules exist wherever a fillable attribute is cast to array or json.
  • Using $request->validate([...]) with a rules array that omits is_admin, but then merging in extra fields with array_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 (including is_admin) back in.
  • Adding $fillable to 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 Eloquent DB::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 calls Model::unguard() globally and is accidentally included in a production service provider - unguard() disables mass-assignment protection for every model until reguard() is called, undoing the per-model $fillable fix.

Additional Resources