Preventing User Enumeration Attack in Laravel Apps

Preventing User Enumeration Attack in Laravel Apps

User enumeration is a brute-force technique that is used to collect or verify valid users' credentials. It belongs to the Identification and Authentication Failures category (AKA broken authentication) which came in 7th place on the Top 10 Web Application Security Risks 2021 by OWASP down from the 2nd place in the 2017 version. It can also be part of phishing attack or other brute-force attacks like credential stuffing and password spraying. Bear in mind that user enumeration vulnerabilities are not exclusive for web applications, it can be found in any system that requires authentication.

Attacking Scenarios

The malicious actor will be basically looking for differences in the authentication server's response, in terms of response time and body, based on the authenticity of submitted credentials. This can happen while using one of the following functionalities:

  • Login
  • Registration
  • Password Reset

Laravel Authentication

Laravel provide us with robust solutions and starter kits for authentication so let's start by creating a new Laravel 9 project and install Jetstram

laravel new user-enum

composer require laravel/jetstream

and for simplicity we will use SQLite 3.36.0

DB_CONNECTION=sqlite
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE="[PROJECT_PATH]/user-enum/database/database.sqlite"
DB_USERNAME=root
DB_PASSWORD=

Login

Out of the box, the error message is consistent whether you entered an incorrect email or password.

New Project.png

Password Reset

Here, the error message discloses that a user doesn't exist in the system so, as we mentioned, we need to make the response message and time consistent.

Screenshot 2022-05-12 201848.png

Moreover, a malicious user can validate user email address existence by sending the reset password request twice. If the email address is valid, on the second attempt a different error message is returned

image.png

Under the hood, Jetstream is using Fortify which is a frontend-agnostic authentication implementation. Our first stop will be the controller registered by Fortify for password reset.

php artisan route:list
  ...
  POST  forgot-password ............. password.email › Laravel\Fortify › PasswordResetLinkController@store

Let's see what the method Laravel\Fortify\PasswordResetLinkController::store do:

  1. First, it validates the submitted email address.
  2. Then the password broker tries to send the reset email to the user of the submitted email address.
  3. Based on the return value of the \Illuminate\Contracts\Auth\PasswordBroker::sendResetLink() method, the response is determined hence the user information is disclosed.
public function store(Request $request): Responsable
{
    $request->validate([Fortify::email() => 'required|email']);

    // We will send the password reset link to this user. Once we have attempted
    // to send the link, we will examine the response then see the message we
    // need to show to the user. Finally, we'll send out a proper response.
    $status = $this->broker()->sendResetLink(
        $request->only(Fortify::email())
    );

    return $status == Password::RESET_LINK_SENT
                ? app(SuccessfulPasswordResetLinkRequestResponse::class, ['status' => $status])
                : app(FailedPasswordResetLinkRequestResponse::class, ['status' => $status]);
}

To fix this, we will create our controller at App\Http\Controller\Auth\PasswordResetLinkController that will extend Fortify's controller. The difference here is that instead of returning the invalid email address error, we will log it and return the same SuccessfulPasswordResetLinkRequestResponse message.

public function store(Request $request): Responsable
{
    $request->validate([Fortify::email() => 'required|email']);
    $email = $request->only(Fortify::email());
    $status = $this->broker()->sendResetLink($email);

    if ($status !== Password::RESET_LINK_SENT) {
        Log::error(__($status, [], 'en'), ['email' => $email]);
    }

    return app(SuccessfulPasswordResetLinkRequestResponse::class, ['status' => Password::RESET_LINK_SENT]);
}

Then we need to define our reset-password route in routes/web.php that will point to the above controller

Route::post('reset-password', 'App\Http\Controllers\Auth\PasswordResetLinkController@store')
    ->name('password.email')
    ->middleware('guest');

At this point, the response message will be the same regardless of the email validity. But, what about the response time? as you can see in the below screenshot, the first request, with an invalid email, took less than a second, while the one with a valid email took over 6 seconds!

image.png

One way to reduce the gap between the two scenarios is to make the Illuminate\Auth\Notifications\ResetPassword notification queueable. So, again we will create our version of this notification at App\Notifications\ResetPassword and make it so. Check the Laravel docs for more information on queuing notifications

<?php

namespace App\Notifications;

use Illuminate\Auth\Notifications\ResetPassword as CoreResetPassword;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;

class ResetPassword extends CoreResetPassword implements ShouldQueue
{
    use Queueable;
}

Then we will override the Illuminate\Auth\Passwords\CanResetPassword::sendPasswordResetNotification() method in the App\Models\User model

public function sendPasswordResetNotification($token)
{
    $this->notify(new ResetPassword($token));
}

Registration

I think you already know the answer to this. Yes, the registration response will disclose whether the user email exist or not.

image.png

To make the response consistent, we will enable user email verification so that an email is sent in both scenarios

Then publish Fortify resources so that its actions become editable. Specifically App\Actions\Fortify\CreateNewUser

php artisan vendor:publish --provider="Laravel\Fortify\FortifyServiceProvider"

The changes is quite simple, first, we remove the unique rule from the email input. Then, if a user with the submitted email address exist, an email will be sent to notify him/her.

public function create(array $input): ?User
{
    Validator::make($input, [
        'name' => ['required', 'string', 'max:255'],
        'email' => ['required', 'string', 'email', 'max:255'],
        'password' => $this->passwordRules(),
        'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '',
    ])->validate();

    if ($user = User::whereEmail($input['email'])->first()) {
        $user->notify(new AlreadyHaveAccount());
        return ;
    }

    return User::create([
        'name' => $input['name'],
        'email' => $input['email'],
        'password' => Hash::make($input['password']),
    ]);
}

And the App\Notifications\AlreadyHaveAccount notification will be

<?php

namespace App\Notifications;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\Lang;

class AlreadyHaveAccount extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct()
    {
    }

    public function via(mixed $notifiable): array
    {
        return ['mail'];
    }

    public function toMail(mixed $notifiable): MailMessage
    {
        return (new MailMessage)
            ->subject('New Registration Attempt with Your Email Address')
            ->line('You are receiving this email because we received a registration request with your registered email address.')
            ->action('Login Instead', route('login'))
            ->line(Lang::get('If you did not try to register a new account, no further action is required.'));
    }
}

Next, we will create a job that accept CreateNewUser action and the submitted input to make the response time consistent

<?php

namespace App\Jobs;

use Illuminate\Auth\Events\Registered;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Laravel\Fortify\Contracts\CreatesNewUsers;

class RegisterUser implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(private CreatesNewUsers $creator, private array $input)
    {
        //
    }

    public function handle()
    {
        $user = $this->creator->create($this->input);

        if ($user) {
            event(new Registered($user));
        }
    }
}

And finally, create a controller for registration, like we did with resetting passwords, and make the register route point to it

<?php

namespace App\Http\Controllers\Auth;

use App\Http\Responses\RegisterResponse;
use App\Jobs\RegisterUser;
use Illuminate\Http\Request;
use Laravel\Fortify\Contracts\CreatesNewUsers;
use Laravel\Fortify\Http\Controllers\RegisteredUserController as FortifyRegisteredUserController;

class RegisteredUserController extends FortifyRegisteredUserController
{
    public function store(Request $request, CreatesNewUsers $creator): RegisterResponse
    {

        RegisterUser::dispatch($creator, $request->all());

        return app(RegisterResponse::class);
    }
}
Route::post('register', 'App\Http\Controllers\Auth\RegisteredUserController@store')
    ->middleware('guest');

Now login, reset password and registration will never disclose existing users information and our app is protected against user enumeration attacks ✌.