Mass-Assignable Model
ID |
mass_assignable_model |
Severity |
high ( |
Remediation Complexity |
trivial |
Remediation Risk |
medium |
Remediation Effort |
low |
Family |
API3:2023 - Broken Object Property Level Authorization |
CWE |
CWE-915 |
Resource |
data_exposure |
Language |
php (Laravel / Eloquent) |
Description
Reports an Eloquent model that opts out of Laravel’s mass-assignment allowlist. A model declaring protected $guarded = []; permits mass assignment on every column of its table:
class User extends Model
{
protected $guarded = []; // every column is writable through $request->all()
}
This is the counterpart to mass_assignment, which reads a payload shape and reports the endpoint that binds a protected field. That approach needs a field list, and an unguarded model has none — the writable set is the live table, defined in a migration rather than in the model. The most dangerous configuration was therefore the one with nothing to enumerate, so it produced no data object and no finding at all.
Both array literal forms are recognised: $guarded = [] and the long-form $guarded = array() still common in legacy Laravel code.
Severity is tiered:
-
HIGH —
$guarded = []: nothing is guarded. -
LOW —
$guardednaming only keys and framework-managed timestamps (id,created_at, …): every business attribute is still assignable.
The following are not reported:
-
$guarded = ['*']— Laravel’s idiom for guarding everything, the opposite of the defect. -
$guarded = ['role', 'is_admin']— a deliberate denylist naming real attributes. -
A model that also declares
$fillable— the allowlist wins in Laravel, andmass_assignmentcan enumerate those fields. -
A non-Eloquent class that happens to have a
$guardedproperty, such as aFormRequest— including one that importsEloquent\Modelfor a type hint, since the class’s own parent is what decides.
Each of those checks is scoped to a single class body rather than to the file, so a file holding an allowlisted base class next to an unguarded model reports the unguarded one, and names it.
Rationale
Laravel blocks mass assignment by default, and a model must explicitly opt in to it. $fillable opts in per attribute; $guarded opts in to everything except the names listed. $guarded = [] therefore reads as "protect nothing", and it is the shortest way to make User::create($request→all()) write any column the table has.
What makes this worse than an over-broad $fillable is that the exposure is invisible at the model. An over-broad $fillable at least lists the fields it accepts, so a reviewer can see 'role' in the array. With an empty $guarded the writable set is whatever the schema currently holds, so:
-
a column added by a later migration —
is_admin,stripe_customer_id,email_verified_at— becomes mass-assignable the moment it exists, with no code change for anyone to review; -
the model file gives a reader no signal at all about what a request can write;
-
an audit of the application’s
$fillabledeclarations passes cleanly while the real hole is elsewhere.
The finding is reported against the model rather than an endpoint, deliberately. The risk is a property of the model: it does not become real only when today’s controllers happen to reach it, and any future create(), fill(), update() or forceFill() on the whole request is a full-row write that the framework will not stop.
Remediation
Replace the empty $guarded with an explicit $fillable allowlist naming only the attributes the model legitimately accepts from a request:
class User extends Model
{
protected $fillable = ['name', 'email'];
}
Guarding by exception cannot be reviewed, because the list of what is exposed does not live in the code. An allowlist can: adding a sensitive column later has no effect until someone edits $fillable, and that edit shows up in review.
If the model must never be mass-assigned at all, say so explicitly with protected $guarded = ['*']; and set its attributes individually.
Two defences worth adding alongside the allowlist:
-
validate with a
FormRequestand pass$request→validated()— never$request→all()— so the accepted shape is declared at the boundary too; -
keep privilege columns out of the model’s writable set entirely and change them through a dedicated, authorized action.
Configuration
keyAttributes lists the attribute names whose presence in $guarded protects nothing of substance — primary keys and framework-managed timestamps. A model guarding only these is reported one severity tier lower rather than treated as guarded.
properties:
keyAttributes:
- id
- _id
- uuid
- ulid
- created_at
- updated_at
- deleted_at
Extend the list when an application has its own always-managed columns — for example a tenant_id set by a global scope. Removing entries makes the detector quieter, never louder: a $guarded list holding any name that is not on this list reads as a deliberate denylist and is not reported.
References
-
OWASP API Security Top 10 (2023) - API3:2023 - Broken Object Property Level Authorization.
-
CWE-915 : Improperly Controlled Modification of Dynamically-Determined Object Attributes.
-
Laravel documentation: Eloquent — Mass Assignment, including the
$guardedand$fillablecontract. -
OWASP Cheat Sheets Series: Mass Assignment Cheat Sheet.