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 may throw a MassAssignmentException in development but expose all attributes if the protection is disabled via Model::unguard() in tests or bootstrap code that was inadvertently left in production.

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, which means nothing is blocked. All attributes can be set via mass assignment regardless of what the controller passes.

$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.

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',
    ];
    // 'is_admin', 'role', 'email_verified_at', 'subscription_tier'
    // are intentionally NOT listed - they cannot be mass-assigned
}

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.

$request->only() in the Controller

<?php
// SECURE: only explicitly named fields are passed to the model
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'],
        ]);

        // SECURE: validated() returns only the declared fields
        $user = User::create([
            ...$validated,
            'role'     => 'user',      // server-set; never from request
            'is_admin' => false,        // server-set; never from request
        ]);

        return response()->json($user, 201);
    }
}

Why this works:

  • $request->validate() returns only the fields declared in the rules array. Any other field in the request body is absent from $validated. Explicitly setting role and is_admin from code (not request data) ensures they cannot be overridden.

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
        return $this->user()->id === (int) $this->route('user');
    }

    public function rules(): array
    {
        return [
            'name'   => ['required', 'string', 'max:255'],
            'bio'    => ['nullable', 'string', 'max:500'],
            'avatar' => ['nullable', 'image', 'max:2048'],
            // 'role', 'is_admin' intentionally absent
        ];
    }
}

// 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() is guaranteed to contain only the fields that passed the declared validation rules. Any field not in rules() - including is_admin - is absent.

Remediation Steps

Locate the Finding

  • Source: $request->all(), $request->input() without field filtering
  • Sink: Model::create(...), $model->update(...), $model->fill(...) called with unfiltered request data

Apply the Fix

  • PRIORITY 1: Add protected $fillable = [...] to every Eloquent model with an explicit list of user-settable fields
  • PRIORITY 2: Replace $request->all() with $request->only([...]) or $request->validated() in controllers
  • PRIORITY 3: Remove protected $guarded = [] from all models; replace with a $fillable definition

Verify the Fix

  • Send a POST/PUT request with "is_admin": 1 or "role": "admin" in the body; confirm those fields are not saved
  • Check php artisan tinker to inspect the stored record after the test request
  • Rescan with the security scanner to confirm the finding is resolved

Check for Similar Issues

Search for: $request->all(), $guarded = [], Model::create(, ->update( in controllers

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.

Additional Resources