Give each model an allowlist, #[Fillable(['display_name', 'bio'])] (or $fillable), and pass only validated input: $profile->update($request->validate([...rules for the editable fields...])). validate() returns just the keys it has rules for. Never use #[Unguarded], $guarded = [], a #[Guarded] blocklist or forceFill() with request data; set role and tenant_id in server-side code.
- Find every place request data reaches a model: create(), update(), fill(), forceFill(), firstOrCreate(), updateOrCreate(), new Model($request->all()) and query-builder update($request->all()), and every model with #[Unguarded], $guarded = [] or a Model::unguard() call.
- Declare #[Fillable([...])] (or the $fillable property) on each model with only the attributes users may edit; Eloquent's fill() then drops every other key.
- In the controller, validate and keep only the validated keys: $request->validate([...]) or a form request's validated() returns just the fields with rules, and $request->safe()->only([...]) narrows it further.
- Set server-owned attributes such as role, tenant_id or user_id in code that has checked who may set them, one attribute at a time, never from the request body.
- Turn on Model::preventSilentlyDiscardingAttributes() in local development, as the Laravel docs suggest (and in tests), so a key that Fillable drops raises an exception instead of vanishing.
- Add regression tests that send the extra fields (role, tenantId and their case variants) in an ordinary update and read the stored record back.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code: the model
// Unguarded: fill(), create() and update() accept every attribute they are given, so whatever
// array reaches them decides which columns change.
#[Unguarded]
class Profile extends Model
{
//
}With IMPL=vulnerable, 3 of 6 tests fail (3 exploit tests).
The vulnerable code: the update
public function update(Request $request): JsonResponse
{
$profile = Profile::where('user_id', $request->user()->id)->firstOrFail();
$profile->update(ProfileFields::toColumns($request->all()));
return response()->json(ProfileFields::toApi($profile, $request->user()->username));
}With IMPL=vulnerable, 3 of 6 tests fail (3 exploit tests).
The fix: an allowlist on the model
// An allowlist: fill(), create() and update() write only these attributes and drop the rest.
// role and tenant_id are set by code that has checked who may set them, one attribute at a time.
#[Fillable(['display_name', 'bio'])]
class Profile extends Model
{
//
}With IMPL=fixed, 6 of 6 tests pass.
The fix: only validated fields reach update()
public function update(Request $request): JsonResponse
{
// Only the keys with rules come back: an allowlist of what this user may edit.
$validated = $request->validate([
'displayName' => ['sometimes', 'string', 'max:100'],
'bio' => ['sometimes', 'string', 'max:1000'],
]);
$profile = Profile::where('user_id', $request->user()->id)->firstOrFail();
$profile->update(ProfileFields::toColumns($validated));
return response()->json(ProfileFields::toApi($profile, $request->user()->username));
}With IMPL=fixed, 6 of 6 tests pass.
A fix that looks safe but is not
// A blocklist: role is guarded, so the privilege-escalation test from the pentest report passes.
// Every other column, tenant_id included, is still mass assignable, and so is every column added
// to the table later.
#[Guarded(['role'])]
class Profile extends Model
{
//
}With IMPL=wrong, 2 of 6 tests fail (2 exploit tests).
Route: the profile comes from the signed-in user
// The profile to update is the signed-in user's own: the route takes no ID, and the controller
// looks the profile up from $request->user(). In production PreventRequestForgery checks this
// PATCH like every other web route; Laravel skips that check while APP_ENV is testing.
Route::patch('/me', [Impl::pick([
'vulnerable' => VulnerableProfileController::class,
'fixed' => FixedProfileController::class,
'wrong' => WrongProfileController::class,
]), 'update'])->middleware('auth');The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
// Exploit: a signed-in member sends extra fields in an ordinary profile update.
/**
* The server may ignore the extra fields (200) or refuse the request (400 or Laravel's 422).
* Either way the stored record keeps the role and tenant the server gave it.
*/
private function assertServerFieldsUnchanged(TestResponse $response): void
{
$this->assertContains($response->status(), [200, 400, 422], "unexpected {$response->status()}: {$response->getContent()}");
$stored = $this->stored('alice');
$this->assertSame('member', $stored['role'], 'role changed');
$this->assertSame('red', $stored['tenantId'], 'tenantId changed');
$this->assertSame([...$this->seed['alice'], 'displayName' => 'Alice Martin'], $stored, 'the stored record changed');
}
public function test_exploit_role_in_the_body_does_not_make_a_member_an_admin(): void
{
$this->assertServerFieldsUnchanged($this->patchMe('alice', ['displayName' => 'Alice Martin', 'role' => 'admin']));
}
public function test_exploit_tenant_id_in_the_body_does_not_move_the_user_to_another_tenant(): void
{
$this->assertServerFieldsUnchanged($this->patchMe('alice', ['displayName' => 'Alice Martin', 'tenantId' => 'blue']));
}
public function test_exploit_case_variants_role_and_tenant_id_do_not_change_the_record_either(): void
{
// The camelCase-to-snake_case step turns Role into role and TenantId into tenant_id.
$this->assertServerFieldsUnchanged(
$this->patchMe('alice', ['displayName' => 'Alice Martin', 'Role' => 'admin', 'TenantId' => 'blue']));
}- #[Guarded(['role'])] (or $guarded = ['role']) only removes the names you thought of. This card's wrong implementation guards role and lets tenant_id through, and every column added to the table later is writable too.
- #[Unguarded], $guarded = [] and Model::unguard() turn mass-assignment protection off; the Laravel docs then say to hand-craft every array you pass to fill(), create() and update(). forceFill() and forceCreate() ignore Fillable and Guarded altogether.
- Renaming keys between the API and the model (camelCase to snake_case, as this card's controller does) widens what reaches the model: Role and TenantId become role and tenant_id. Laravel also compares guarded names case-insensitively and, when anything is guarded, refuses keys that are not table columns, but none of that helps with a column that is simply missing from the list.
- validated() returns a whole nested array when the rule is just 'array': the docs advise listing the allowed keys (array:name,username) or validating each nested key.
- $request->all(), $request->input() and $request->except('role') hand over everything else the client sent; except() is a blocklist too.
- Hidden or read-only form inputs are not protection: the client can send any field it likes. The validation rules and Fillable decide what is written, not the HTML.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs mass-assignment/php-laravel does this for you in the pinned composer image, so it needs Docker).
- Against your own API, sign in as an ordinary user and send your profile update with role, tenantId and their case variants added, then read the record back: nothing but the editable fields may change.
- grep for Unguarded, $guarded, unguard(, forceFill(, ->all() and ->except( near create(), update() and fill(); Semgrep's Laravel rule only matches $guarded = [] and found nothing here.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | php.laravel.security.laravel-dangerous-model-construction.laravel-dangerous-model-construction | No findings on this card. The rule matches the property form, $guarded = [] inside a Model subclass, and the vulnerable model uses Laravel 13's #[Unguarded] attribute, which means the same thing. A probe with protected $guarded = [] was flagged. Neither form of the blocklist in the wrong model (#[Guarded(['role'])]) is matched.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3); semgrep scan --config r/<id> app routes |
| Semgrep | r/php (every PHP rule in the Semgrep registry, 47 rules) | No findings on any implementation: no rule follows request data into fill(), create() or update().Checked: Semgrep 1.180.0 (CE, semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3); semgrep scan --config r/php app routes config bootstrap |
| Psalm | taint analysis (all taint kinds)Psalm taint analysis with the Laravel plugin | No issues: Psalm has no taint kind for mass assignment, so request data reaching update() is not a finding.Checked: Psalm 7.0.0-beta24 with psalm/plugin-laravel 4.16.6; psalm --taint-analysis over app/ and routes/ |
CodeQL has no PHP support: the CodeQL supported languages page lists C/C++, C#, GitHub Actions, Go, Java, Kotlin, JavaScript, Python, Ruby, Rust, Swift and TypeScript.
| Component | Version |
|---|---|
| Language | PHP |
| Runtime | PHP 8.5.11 (CLI) |
| Framework | Laravel 13.34.0 (Eloquent over SQLite) |
| laravel/framework | 13.34.0 |
| phpunit/phpunit | 13.4.0 |
| pdo_sqlite (bundled with PHP) | PHP 8.5.11; SQLite 3.53.4 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the mass-assignment/php-laravelfolder.
composer install --no-interaction --no-progress# Expected: every test passes
IMPL=fixed vendor/bin/phpunit --log-junit reports/junit.xml
# Expected: an exploit test fails
IMPL=vulnerable vendor/bin/phpunit --log-junit reports/junit.xml
# Expected: a test fails
IMPL=wrong vendor/bin/phpunit --log-junit reports/junit.xmlWithout PHP installed, run the same commands with Docker in the image the card was verified in, pinned by its digest. The named volumes keep the downloaded dependencies and the build cache between runs, so the test runs need no network.
docker run --rm --init \
-v "$PWD":/card -w /card \
-v fix-laravel-composer-cache:/tmp/cache \
composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
sh -c 'composer install --no-interaction --no-progress'# Expected: every test passes
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-v fix-laravel-composer-cache:/tmp/cache \
-e IMPL=fixed \
composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
sh -c 'vendor/bin/phpunit --log-junit reports/junit.xml'
# Expected: an exploit test fails
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-v fix-laravel-composer-cache:/tmp/cache \
-e IMPL=vulnerable \
composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
sh -c 'vendor/bin/phpunit --log-junit reports/junit.xml'
# Expected: a test fails
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-v fix-laravel-composer-cache:/tmp/cache \
-e IMPL=wrong \
composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
sh -c 'vendor/bin/phpunit --log-junit reports/junit.xml'Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs mass-assignment/php-laravel runs all three modes and checks the expected results, in the container, so it needs Docker and Node.js but not the toolchain.
Verified
- Machine
- Alpine Linux 3.24 in the composer:2.10.3 image (Docker 29.4.3 on macOS 27.0, arm64)
- Toolchain
- php 8.5.11 (CLI)
- laravel/framework 13.34.0
- phpunit/phpunit 13.4.0
- composer 2.10.3
- semgrep 1.180.0
- psalm 7.0.0-beta24 with psalm/plugin-laravel 4.16.6
- Matrix
- fixed: 6 of 6 tests pass; vulnerable: 3 of 6 tests fail (3 exploit tests); wrong: 2 of 6 tests fail (2 exploit tests)
- Test results
- fixed 6/6 pass; vulnerable 3/6 fail (3 exploit tests); wrong 2/6 fail (2 exploit tests), each test run in a container with --network=none after composer install
- Dependency freshness
- Laravel 13.35.0 (2026-10-06) is newer but was five days old and is not a security release, so the cards pin 13.34.0 (2026-09-29); laravel/prompts is held at 0.3.24 for the same reason (0.3.25 is from 2026-10-06), and PHPUnit 13.4.1 (2026-10-05) was six days old, so the cards use 13.4.0 (2026-10-02). Every other locked package is at least seven days old. composer audit reported no advisories for the lock file. PHP 8.5.11 (2026-09-24) is the current 8.5 security release.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP Mass Assignment Cheat Sheet
- OWASP API Security Top 10 2023: API3 Broken Object Property Level Authorization
- Laravel 13.x: Mass assignment (Fillable)
- Laravel 13.x: Allowing mass assignment (Unguarded)
- Laravel 13.x: Mass assignment exceptions (preventSilentlyDiscardingAttributes)
- Laravel 13.x: Working with validated input
- Laravel 13.x: Validating arrays (list the allowed keys)
- CWE-915: Improperly Controlled Modification of Dynamically-Determined Object Attributes