Josh Davies

Senior Software Engineer

I build beautiful, engaging, and perfectly executed frontend and backend experiences. While finding time to write, hike and read.

Blog

Don't Let the Request Pick the Columns

  • security
  • laravel
  • php
  • typescript
  • node

Here is a profile update endpoint that looks completely fine in code review:

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

The form has two fields. Name and bio. The test posts name and bio. Everything passes.

Now somebody opens dev tools and adds one more field to the request: role=admin.

The form is not the contract

The HTML form shows what you expect people to send. It does not limit what they can send. Anyone with curl can post whatever keys they want, and $request->all() will hand every one of them to your model.

This bug has a name, mass assignment, and it is old. Every major framework ships some kind of guard. The guard only works if you leave it on.

Laravel's guard, and how people turn it off

Eloquent protects you with $fillable or $guarded. Only listed attributes can be mass assigned:

class User extends Authenticatable
{
    protected $fillable = ['name', 'email', 'bio'];
}

The problem is that this list lives far away from the controller. Somebody adds role to $fillable because the admin panel needed it, and now every endpoint that calls update($request->all()) can set roles too.

And then there is the classic shortcut:

protected $guarded = [];

That line turns the guard off for the whole model. It is in a lot of codebases because it makes the MassAssignmentException go away. It also makes the bug go away from your error log and into production.

Name the fields in the controller

The fix is boring. Validate the fields this endpoint accepts and only pass those through:

public function update(Request $request)
{
    $data = $request->validate([
        'name' => ['required', 'string', 'max:100'],
        'bio'  => ['nullable', 'string', 'max:500'],
    ]);
 
    $request->user()->update($data);
 
    return back();
}

validate() returns only the keys you wrote rules for. Extra keys get dropped. Now the endpoint has its own contract, and $fillable is a second layer instead of the only one.

If you use Form Requests, the same idea is $request->validated(), or $request->safe()->only(['name', 'bio']) when you want to be explicit about a subset.

One gotcha. A bare array rule lets the whole nested array through, whatever is inside it:

'settings' => ['array'],

Either write rules for the nested keys (settings.theme, settings.timezone) or restrict the allowed keys with array:theme,timezone.

Make it loud in development

By default, Eloquent silently drops attributes that are not fillable. That is safe, but it hides mistakes. You think you saved a field and you did not.

Laravel can throw instead:

// AppServiceProvider::boot()
Model::preventSilentlyDiscardingAttributes(! $this->app->isProduction());

Now a stray key blows up in local and in tests, where you will actually notice it.

Node has the same bug with a different accent

The TypeScript version usually looks like this:

app.patch("/profile", async (req, res) => {
  await prisma.user.update({
    where: { id: req.user.id },
    data: req.body,
  });
  res.sendStatus(204);
});

Prisma will happily write any column you hand it. There is no $fillable here. req.body is the column list.

Parse it into a shape first:

import { z } from "zod";
 
const ProfileUpdate = z.object({
  name: z.string().min(1).max(100),
  bio: z.string().max(500).optional(),
});
 
app.patch("/profile", async (req, res) => {
  const data = ProfileUpdate.parse(req.body);
 
  await prisma.user.update({ where: { id: req.user.id }, data });
  res.sendStatus(204);
});

A plain z.object strips keys it does not know about. If you would rather reject the request outright, add .strict() and unknown keys become a validation error.

Same story with { ...req.body } going into an insert, or Object.assign(user, req.body) before a save. If the client's keys become your columns, the client is writing your schema.

Write the test that sends the extra field

Most tests post exactly what the form posts, so they never catch this. Add one that does what an attacker would do:

it('ignores role in a profile update', function () {
    $user = User::factory()->create(['role' => 'member']);
 
    $this->actingAs($user)
        ->patch('/profile', ['name' => 'New Name', 'role' => 'admin'])
        ->assertRedirect();
 
    expect($user->fresh())
        ->name->toBe('New Name')
        ->role->toBe('member');
});

It takes two minutes. It also stops the next person who adds role to $fillable from quietly opening the door again.

The short version

  • Never pass $request->all() or req.body straight into create or update.
  • Validate per endpoint and use only the validated data.
  • Keep $fillable tight and delete $guarded = [] when you find it.
  • Turn on preventSilentlyDiscardingAttributes outside production.
  • Test with an extra field the form does not have.

The request gets to suggest values. It does not get to pick the columns.

← All posts