Andrés
•
18 April 2024
Let’s say we have a simple endpoint in our Rails application for our users to enter the platform:
def create
user = User.find_by(email: params[:email].downcase)
if user && user.authenticate(params[:password])
log_in user
redirect_to user
else
flash.now[:danger] = 'Combinación de email/password incorrecta'
render 'new'
end
end
The above code looks good, is functional, and you have probably followed a very similar logic for the logins you have programmed so far. But it has a security problem: the conditional if
will not take the same response time if the user does not exist or if the user exists, but the password is not correct.
An enumeration attack based on response times is what I showed above. An attacker will be able to test emails by brute force and will be able to tell when an email exists or not in our database by analyzing the response times of our web application or rather of the http request.
A very simple example using the above code would give us response times like this:
In blue would be the failed login attempts with users and passwords that are not correct. In red would be the attempts where the user does exist, but we do not know the password. As we can see, the differences in response times are remarkable.
If we were in the position of an attacker and we tried 1000 emails, where most of the responses are between 20 and 30 ms, but only one gives us 200 ms of response, then we would know that we found something there.
In Rails 7.1 a new method called authenticate_by
was introduced in order to prevent this type of attack vector in our Rails applications by responding with a similar time if the user exists or not in our database.
Before authenticate_by
:
User.find_by(email: ".....")&.authenticate("...")
After authenticate_by
:
User.authenticate_by(email: "....", password: "...")
Now, if we take this back to our previous example, then our code might look like this:
def create
if user = User.authenticate_by(email: params[:email], password: params[:password])
log_in user
redirect_to '/home'
else
flash[:notice] = 'Combinación de email/password incorrecta'
p 'HERE'
redirect_to root_path
end
end
Performing the same tests from the browser we have these samples in terms of response times:
.
And as we can see, both requests with emails that exist and those that do not exist in our database respond with similar times (215..245 ms) making it impossible to enumerate accounts by response time.
This is in a best case scenario, this method does not handle all the business logic and may in certain cases such as if you want to control failed login attempts on an account you add code that produces a noticeable time difference and again an enumeration attack based on response times may occur.
For the curious, authenticate_by
has a not very complex definition, where the key lies in the if
on line 45:
if record = find_by(attributes.except(*passwords.keys))
record if passwords.count { |name, value| record.public_send(:"authenticate_#{name}", value) } == passwords.size
else
self.new(passwords)
nil
end
What it does here is very similar to what was done before with Customer.find_by(email: "....")&.authenticate("...")
, but in the case that the user is not found, that is in the else
block, it calls the new
method to generate a new instance of the class passing as parameters the passwords that are being used in the login attempt. This forces that even though no record was found the passwords must still be encrypted, which results in a similar response time as if the record was found and the passwords had to be encrypted to compare the hashes.
To conclude, it is important to note that, as PR author mentions, authenticate_by does not guarantee that the authentication time is always constant, especially if the username column is not backed by an index. Regardless, this addition represents a great advancement for our applications by avoiding the possibility of time-based enumeration attacks. Ultimately, it provides us with an additional layer of security in a critical aspect of our web applications.
Happy Coding!