Skip to content

Mass Assignment Vulnerabilities Analyzer

Analyzer IDCategorySeverityTime To Fix
mass-assignment-vulnerabilities🛡️ SecurityHigh25 minutes

What This Checks

Detects mass assignment vulnerabilities where unfiltered user input is passed to Eloquent models or query builders, allowing attackers to modify unintended database fields. This analyzer checks for:

  • Models without $fillable or $guarded protection
  • Dangerous method calls with request()->all()
  • Query builder operations with raw request data
  • Multiple unsafe request data retrieval patterns
  • Missing $hidden attributes for sensitive fields (password, api_token, remember_token, etc.)
  • Relationship operations with unfiltered data (sync(), attach(), updateExistingPivot(), etc.)
  • Nested mass assignment via dot notation patterns like $request->input('user.profile')
  • Blacklist filtering warnings when using request()->except() instead of the safer only() approach

Why It Matters

Mass assignment is one of the most dangerous security vulnerabilities in Laravel applications because it allows attackers to:

  • Privilege Escalation: Set is_admin = 1 to gain administrative access
  • Account Takeover: Modify email addresses or passwords of other users
  • Data Manipulation: Update protected fields like prices, status, or verification timestamps
  • Bypass Business Logic: Circumvent validation rules and access controls

Real-World Impact: GitHub 2012

In March 2012, GitHub suffered a mass assignment vulnerability that allowed attackers to add SSH keys to any repository, including Rails's own codebase. This single vulnerability led to:

  • Complete system compromise requiring emergency shutdown
  • Forced suspension of GitHub services for security audit
  • Major architectural security overhaul

The attack exploited unprotected mass assignment by sending:

bash
POST /repositories
{
  "public_key[id]": "attacker_key_id"
}

Modern Laravel applications face identical risks when using request()->all() without proper model protection.

How to Fix

Quick Fix (5 minutes)

Add mass assignment protection to all Eloquent models immediately:

Before (❌):

php
class User extends Model
{
    // No protection - vulnerable to mass assignment
}

After (✅):

php
class User extends Model
{
    // Whitelist approach (recommended)
    protected $fillable = ['name', 'email', 'bio', 'avatar'];

    // OR blacklist approach
    protected $guarded = ['id', 'is_admin', 'role', 'created_at', 'updated_at'];
}

Controller update:

Before (❌):

php
public function update(Request $request, User $user)
{
    $user->update($request->all()); // Dangerous
}

After (✅):

php
public function update(Request $request, User $user)
{
    $user->update($request->only(['name', 'email', 'bio'])); // Safe
}

Proper Fix (25 minutes)

Step 1: Define model protection strategy

Choose between $fillable (whitelist - recommended) or $guarded (blacklist):

php
// RECOMMENDED: Whitelist approach
class User extends Model
{
    protected $fillable = [
        'name',
        'email',
        'bio',
        'avatar',
    ];
}

// ALTERNATIVE: Blacklist approach
class User extends Model
{
    protected $guarded = [
        'id',
        'is_admin',
        'role',
        'email_verified_at',
        'remember_token',
        'created_at',
        'updated_at',
    ];
}

Best Practice: Use $fillable for better security - new fields are protected by default.

Step 2: Create Form Request validation

Generate validated request class:

bash
php artisan make:request UpdateUserRequest

Before (❌):

php
public function update(Request $request, User $user)
{
    $user->update($request->all());
    return $user;
}

After (✅):

php
// app/Http/Requests/UpdateUserRequest.php
class UpdateUserRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->id === $this->route('user')->id;
    }

    public function rules(): array
    {
        return [
            'name' => 'sometimes|string|max:255',
            'email' => 'sometimes|email|unique:users,email,'.$this->user()->id,
            'bio' => 'nullable|string|max:1000',
            'avatar' => 'nullable|url',
        ];
    }
}

// Controller
public function update(UpdateUserRequest $request, User $user)
{
    $user->update($request->validated()); // Only validated fields
    return $user;
}

Step 3: Fix all dangerous patterns

Pattern 1: Eloquent create/update with request()->all()

Before (❌):

php
User::create($request->all());
Product::create($request->input());
$user->update($request->post());
$user->fill($request->get())->save();

After (✅):

php
User::create($request->validated());
Product::create($request->only(['name', 'description', 'price']));
$user->update($request->validated());
$user->fill($request->validated())->save();

Pattern 2: Query Builder operations

Before (❌):

php
DB::table('users')->update($request->all());
DB::table('products')->insert($request->all());

After (✅):

php
DB::table('users')->update([
    'name' => $request->validated('name'),
    'email' => $request->validated('email'),
    'updated_at' => now(),
]);

DB::table('products')->insert([
    'name' => $request->validated('name'),
    'price' => $request->validated('price'),
    'created_at' => now(),
]);

Pattern 3: Empty $guarded array

Before (❌):

php
class Product extends Model
{
    protected $guarded = []; // Allows mass assignment of ALL fields
}

After (✅):

php
class Product extends Model
{
    protected $fillable = ['name', 'description', 'price', 'stock'];
}

Pattern 4: Missing $hidden for sensitive data

Before (❌):

php
class User extends Model
{
    protected $fillable = ['name', 'email', 'password'];
    // Sensitive fields exposed in JSON/array output
}

After (✅):

php
class User extends Model
{
    protected $fillable = ['name', 'email', 'password'];

    // Hide sensitive fields from serialization
    protected $hidden = [
        'password',
        'remember_token',
        'api_token',
        'two_factor_secret',
        'two_factor_recovery_codes',
    ];
}

Pattern 5: Relationship operations with unfiltered data

Before (❌):

php
public function updateRoles(Request $request, User $user)
{
    $user->roles()->sync($request->input('roles')); // Dangerous
    $user->permissions()->attach($request->all()); // Very dangerous
}

After (✅):

php
public function updateRoles(UpdateRolesRequest $request, User $user)
{
    // Validate role IDs exist and user is authorized to assign them
    $validRoleIds = Role::whereIn('id', $request->input('roles', []))
        ->where('assignable', true)
        ->pluck('id')
        ->toArray();

    $user->roles()->sync($validRoleIds);
}

// Or use validated data with explicit allowed values
public function updatePermissions(Request $request, User $user)
{
    $validated = $request->validate([
        'permissions' => 'array',
        'permissions.*' => 'exists:permissions,id',
    ]);

    $user->permissions()->sync($validated['permissions'] ?? []);
}

Pattern 6: Blacklist filtering (except) vs Whitelist (only)

Before (❌):

php
// Blacklist approach - easy to forget new sensitive fields
$user->update($request->except(['is_admin', 'role']));

// New fields added later may be vulnerable
// e.g., 'api_token', 'email_verified_at' could be mass-assigned

After (✅):

php
// Whitelist approach - only explicitly allowed fields
$user->update($request->only(['name', 'email', 'bio', 'avatar']));

// Or best: use validated data from Form Request
$user->update($request->validated());

Why whitelist is safer: When new fields are added to your model, the blacklist approach requires you to remember to add them to except(). With whitelist (only() or validated()), new fields are automatically protected.

Pattern 7: Nested mass assignment

Before (❌):

php
// Dot notation can bypass simple $fillable checks
$user->update($request->input('user')); // Gets nested array
$user->profile()->update($request->input('user.profile'));

After (✅):

php
// Explicitly validate and extract nested data
$validated = $request->validate([
    'user.name' => 'required|string|max:255',
    'user.email' => 'required|email',
    'user.profile.bio' => 'nullable|string|max:1000',
]);

$user->update([
    'name' => $validated['user']['name'],
    'email' => $validated['user']['email'],
]);

$user->profile()->update([
    'bio' => $validated['user']['profile']['bio'] ?? null,
]);

Step 4: Test your implementation

Create automated tests:

php
// tests/Feature/MassAssignmentTest.php
public function test_cannot_mass_assign_admin_role()
{
    $response = $this->postJson('/api/users', [
        'name' => 'John Doe',
        'email' => 'john@example.com',
        'is_admin' => 1,  // Attempt to set admin
    ]);

    $user = User::where('email', 'john@example.com')->first();

    $this->assertFalse($user->is_admin); // Should NOT be admin
}

Manual testing with curl:

bash
# Attempt malicious request
curl -X POST http://localhost/api/users \
  -H "Content-Type: application/json" \
  -d '{"name":"John","email":"john@test.com","is_admin":1,"role":"admin"}'

# Verify in database
php artisan tinker
>>> User::latest()->first()
>>> # is_admin and role should NOT be set

References