Software Securityhome
Fix a findingSearch
Fix card · A08:2025 Software or Data Integrity Failures

Fix mass assignment (over-posting) in PHP and Laravel

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

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.

Stack
  • Language PHP
  • Runtime PHP 8.5.11 (CLI)
  • Framework Laravel 13.34.0 (Eloquent over SQLite)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks9 cards
Download the project ZIP, 52 KB
On this page

Steps

  1. 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.
  2. Declare #[Fillable([...])] (or the $fillable property) on each model with only the attributes users may edit; Eloquent's fill() then drops every other key.
  3. 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.
  4. 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.
  5. 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.
  6. Add regression tests that send the extra fields (role, tenantId and their case variants) in an ordinary update and read the stored record back.

Vulnerable, fixed and wrong

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

Vulnerableapp/Profiles/Vulnerable/Profile.phpPHP
// 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

Vulnerable…/Profiles/Vulnerable/ProfileController.phpPHP
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

Fixedapp/Profiles/Fixed/Profile.phpPHP
// 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()

Fixedapp/Profiles/Fixed/ProfileController.phpPHP
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

Wrong fixapp/Profiles/Wrong/Profile.phpPHP
// 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

routes/web.phpPHP
// 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 regression test

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.

Testtests/Feature/ProfileTest.phpPHP
// 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']));
}

Pitfalls

  • #[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.

Verify the fix

  1. 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).
  2. 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.
  3. 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.

Scanner rules

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.

Scanner rules for Fix mass assignment (over-posting) in PHP and Laravel
ToolRuleWhat it found here
Semgrepphp.laravel.security.laravel-dangerous-model-construction.laravel-dangerous-model-constructionNo 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
Semgrepr/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
Psalmtaint analysis (all taint kinds)Psalm taint analysis with the Laravel pluginNo 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.

Libraries and versions

Libraries and versions
ComponentVersion
LanguagePHP
RuntimePHP 8.5.11 (CLI)
FrameworkLaravel 13.34.0 (Eloquent over SQLite)
laravel/framework13.34.0
phpunit/phpunit13.4.0
pdo_sqlite (bundled with PHP)PHP 8.5.11; SQLite 3.53.4

Run the tests

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.

Shell
composer install --no-interaction --no-progress
Shell
# 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.xml

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

Shell
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'
Shell
# 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.

Download the project ZIP, 52 KB

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

  1. OWASP Mass Assignment Cheat Sheet
  2. OWASP API Security Top 10 2023: API3 Broken Object Property Level Authorization
  3. Laravel 13.x: Mass assignment (Fillable)
  4. Laravel 13.x: Allowing mass assignment (Unguarded)
  5. Laravel 13.x: Mass assignment exceptions (preventSilentlyDiscardingAttributes)
  6. Laravel 13.x: Working with validated input
  7. Laravel 13.x: Validating arrays (list the allowed keys)
  8. CWE-915: Improperly Controlled Modification of Dynamically-Determined Object Attributes

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.