Security Tip: Leaking Model Existence

[Tip#27] Observing the existence of something you can't access still tells you something important, even if you can't access it.

Security Tip: Leaking Model Existence

I’ve talked previously about how much I love Laravel’s Policy Objects. They are perfect for controlling authorisation to models, routes, and well, anything within your application. The one humongous downside of using them is they return a 403 Forbidden HTTP status code when authorisation fails. Compare this to a route with model binding or using the findOrFail() Eloquent method, which return a 404 Not Found HTTP status code.

Consider the following requests and status code response:

GET /users/8760 => 403  // User exists but we don't have access
GET /users/8764 => 403  // User exists but we don't have access
GET /users/8765 => 200  // We have access - is this me?
GET /users/8766 => 404  // User does not exist
GET /users/8770 => 404  // User does not exist

With this information we can reliably assume that there are at most 8765 users in the database and the IDs are likely to be incremental.

If we’re trying to attack this site, knowing the ID scheme is incredibly helpful for any attacks that requite the User ID. We now have an upper limit to generate IDs within.

It also gives us an idea of how popular the app is. For example, if there are only 100 users, it might not be worth attacking, while 100,000 users has a much greater potential payoff of data we could steal.

To (mostly) work around the problem, and allow for more flexibility with authorisation failures, Tim MacDonald introduced a new feature into the authorisation system within Laravel that allows you to specify the HTTP status code returned when authorisation fails.

In our example above, this allows us to specify that a 404 is returned when authorisation is not allowed. In other situations you may wish to return a contextually appropriate status code. Maybe the user has run out of API calls, or the feature they are trying to access is disabled? Rather than a blanket 403, you can reject with any status code you like. Yep, even a 418… 😉

Applying this new feature in our app would give us these responses, to mask the information we can glean from the status codes:

GET /users/8760 => 404  // ???
GET /users/8764 => 404  // ???
GET /users/8765 => 200  // We have access - is this me?
GET /users/8766 => 404  // ???
GET /users/8770 => 404  // ???

There are three ways you can take advantage of this new feature within your Policies or Gates, and I’ll summarise them for you, but the best place to learn how it all works is the PR itself.

First, there are new methods on the `Illuminate\Auth\Access\Response` class:

return Response::deny()->withStatus(404);
return Response::deny()->asNotFound();     // i.e. 404 helper
return Response::denyWithStatus(404);
return Response::denyAsNotFound();         // i.e. 404 helper

The `Illuminate\Auth\Access\HandlesAuthorization` policy helper trait has been updated too with matching methods:

class ProjectPolicy
{
    use HandlesAuthorization;

    public function view(User $user, Project $project)
    {
        return $this->deny()->withStatus(404);
        return $this->deny()->asNotFound();
        return $this->denyWithStatus(404);
        return $this->denyAsNotFound();
    }
}

And finally, you can throw the exceptions themselves and pass in the status codes. This can be done anywhere in your code, not just within Policies or Gates - the example given in the PR is within Form Request objects. This sounds really helpful if you need to deal with any custom authorisation logic.

throw (new AuthorizationException)->withStatus(404);
throw (new AuthorizationException)->asNotFound();

To find out more about this feature, check out the release announcement on Twitter:

Timing Attacks…

As I mentioned above, this mostly works around the problem, but it doesn’t completely solve it. Status codes may be used mask the existence of models by returning a 404 regardless of the existence of the model. However, measuring the timing of the response may reveal the difference between a model existing or not.

Consider the query time: It might take only 5ms for the database to say “model not found” and the app can return 404 straight away, while it might take 25ms to return the model and another 5ms for the app to process the authorisation checks, and then return a 404.

5ms vs 30ms.

That is noticeable over the internet, even if individual requests may vary in response time. Make enough requests and you can measure the differences.

Ultimately, timing attacks are difficult to avoid, and we don’t have time to cover them today, but I wanted to mention them so you’re informed of why this isn’t the prefect solution.

For more about timing attacks, check out: In Depth: Insecure Direct Object Reference (IDOR) vulnerabilities & In Depth: Timing Attacks.


If you found this security tip useful, subscribe to get weekly Security Tips straight to your inbox. Upgrade to a premium subscription for exclusive monthly In Depth articles, or drop a coin in the tip jar to show your support.

Looking for a Laravel Security Audit / Penetration Test, or a budget-friendly Security Review? Feel free to reach out! You can also connect with me on Bluesky, or other socials. And don’t miss Practical Laravel Security, my interactive course designed to boost your Laravel security skills.