Michael Lubas, 2023-02-22
Phoenix applications with user accounts share a common set of features: a login page, account creation, and password reset via an email. These systems are exposed to bot attacks, and developers should be aware of the risks that the public internet imposes on their software. This article will show a risk assessment of a Phoenix application’s login system, and how to use the Hammer library to rate limit the number of requests to each endpoint based on IP address.
Consider the code generated by mix phx.gen.auth. Focus on the following:
An attacker wants to cause harm to a business whose web application has these functions. What can the attacker do? This question is open ended, and varies from business to business. Here are the three risks common to the majority of cases:
An attacker has a number of stolen username/password pairs, and writes a bot to do hundreds of login attempts per minute. The goal is unauthorized access to user accounts. For now, imagine the attacker is sending all these requests from a single IP address.
If a site requires a credit card to signup, that’s not a defense against spam account creation. On the contrary, these sites are sought out by criminals doing credit card fraud, because they need a way to test thousands of stolen cards by making a small purchase. Even if the application does not have payment functionality, spam account creation can lead to thousands of “confirm your account” emails being sent, which may cause the transactional email provider to suspend service.
An attacker writing a bot to send out spam password reset emails seems low risk. This is incorrect, if the attacker triggers enough email send events, the application’s transactional email provider may terminate service, causing email for the entire application to stop working. That is a major incident.
In the example Phoenix application, nail
, these functions correspond to the following HTTP requests:
POST /users/log_in
POST /users/register
POST /users/reset_password
Now that the risk from bots has been explained, we will limit number of requests one IP address can send in a 60 second period. This will be done via the Hammer library and conn.remote_ip
.
First, install hammer in the mix.exs
file:
defp deps do
[
{:bcrypt_elixir, "~> 3.0"},
...
{:hammer, "~> 6.1"}
]
end
Next, edit config/config.exs
to use ETS as the backend:
config :hammer,
backend: {Hammer.Backend.ETS,
# 4 hours (240 minutes)
[expiry_ms: 60_000 * 60 * 4,
# 10 minutes
cleanup_interval_ms: 60_000 * 10]}
With hammer configured, wrap the appropriate controller functions with a rate limit. First, limit login attempts based on IP address. This is done with the Hammer.check_rate
function and conn.remote_ip
.
POST /users/log_in
- Request to rate limit
lib/nail_web/router.ex
scope "/", NailWeb do
pipe_through [:browser, :redirect_if_user_is_authenticated]
...
post "/users/log_in", UserSessionController, :create
lib/nail_web/controllers/user_session_controller.ex
def create(conn, params) do
# 5 login attempts per minute
case Hammer.check_rate(conn.remote_ip, 60_000, 5) do
{:allow, _count} ->
do_create(conn, params)
{:deny, _limit} ->
render(conn, "new.html", error_message: "Rate limited")
end
end
def do_create(conn, %{"user" => user_params}) do
%{"email" => email, "password" => password} = user_params
if user = Accounts.get_user_by_email_and_password(email, password) do
UserAuth.log_in_user(conn, user, user_params)
else
# In order to prevent user enumeration attacks, don't disclose whether the email is registered.
render(conn, "new.html", error_message: "Invalid email or password")
end
end
This will limit the number of requests one IP can send to 5 per minute.
Next, limit new account creation:
POST /users/register
- Request to rate limit
lib/nail_web/router.ex
scope "/", NailWeb do
pipe_through [:browser, :redirect_if_user_is_authenticated]
get "/users/register", UserRegistrationController, :new
post "/users/register", UserRegistrationController, :create
lib/nail_web/controllers/user_registration_controller.ex
def create(conn, params) do
# 5 attempts to create a new account per minute
case Hammer.check_rate(conn.remote_ip, 60_000, 5) do
{:allow, _count} ->
do_create(conn, params)
{:deny, _limit} ->
render(conn, "new.html", error_message: "Rate limited")
end
end
def do_create(conn, %{"user" => user_params}) do
case Accounts.register_user(user_params) do
{:ok, user} ->
{:ok, _} =
Accounts.deliver_user_confirmation_instructions(
user,
&Routes.user_confirmation_url(conn, :edit, &1)
)
conn
|> put_flash(:info, "User created successfully.")
|> UserAuth.log_in_user(user)
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, "new.html", changeset: changeset)
end
end
The code is similar to the function that rate limits login attempts.
Finally, rate limit password reset emails:
POST /users/reset_password
- Request to rate limit
lib/nail_web/router.ex
scope "/", NailWeb do
pipe_through [:browser, :redirect_if_user_is_authenticated]
...
post "/users/reset_password", UserResetPasswordController, :create
end
lib/nail_web/controllers/user_reset_password_controller.ex
def create(conn, params) do
# 5 password resets per IP per minute
case Hammer.check_rate(conn.remote_ip, 60_000, 5) do
{:allow, _count} ->
do_create(conn, params)
{:deny, _limit} ->
conn
|> put_flash(:error, "Rate limited")
|> redirect(to: "/")
end
end
def do_create(conn, %{"user" => %{"email" => email}}) do
if user = Accounts.get_user_by_email(email) do
Accounts.deliver_user_reset_password_instructions(
user,
&Routes.user_reset_password_url(conn, :edit, &1)
)
end
conn
|> put_flash(
:info,
"If your email is in our system, you will receive instructions to reset your password shortly."
)
|> redirect(to: "/")
end
If you are reading this article because your own IP based rate limiting is not working, due to the conn.remote_ip
value being different from what you expected, the most likely reason is your application is behind a proxy. See the remote_ip library to fix this issue.
Rate limiting on IP address is an excellent defense, however there are some situations where you may run into problems. For example, a large office where users share an IP address, and need to access the same web application at the same time may result in legitimate users triggering the rate limit.
Attackers also have tools to bypass IP based rate limiting, for example by proxying their traffic through a cloud service such as AWS API gateway. Paraxial.io is able to defend against this attack with a special plug that compares the conn.remote_ip
against a radix trie of know cloud IP ranges, and is a great choice for businesses using Elixir in production.
Paraxial.io stops data breaches by helping developers ship secure applications. Get a demo or start for free.
Subscribe to stay up to date on new posts.