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. 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, 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:
$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.
$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 settingroleandis_adminfrom 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 inrules()- includingis_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$fillabledefinition
Verify the Fix
- Send a POST/PUT request with
"is_admin": 1or"role": "admin"in the body; confirm those fields are not saved - Check
php artisan tinkerto 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.