From 5b1f342b136a180b080491159c3b0e533693a531 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cenk=20K=C3=BCc=C3=BCk?= Date: Mon, 6 Apr 2026 23:14:39 +0100 Subject: [PATCH 1/7] Implement rate limiting --- lib/plausible/auth/auth.ex | 31 +++++++++++ .../controllers/auth_controller.ex | 51 +++++++++++++++---- .../controllers/auth_controller_test.exs | 20 ++++++++ 3 files changed, 93 insertions(+), 9 deletions(-) diff --git a/lib/plausible/auth/auth.ex b/lib/plausible/auth/auth.ex index 91606dcde5da..c4605bf20ad0 100644 --- a/lib/plausible/auth/auth.ex +++ b/lib/plausible/auth/auth.ex @@ -21,6 +21,7 @@ defmodule Plausible.Auth do else @ip_rate_limit 5 @user_rate_limit 5 + @activation_request_limit if(Mix.env() == :test, do: 100_000, else: 5) end @rate_limits %{ @@ -43,6 +44,36 @@ defmodule Plausible.Auth do prefix: "password-change:user", limit: 5, interval: :timer.minutes(20) + }, + activation_ip: %{ + prefix: "activation:ip", + limit: 10, + interval: :timer.minutes(5) + }, + activation_user: %{ + prefix: "activation:user", + limit: 10, + interval: :timer.minutes(5) + }, + activation_request_ip: %{ + prefix: "activation-request:ip", + limit: @activation_request_limit, + interval: :timer.minutes(10) + }, + activation_request_user: %{ + prefix: "activation-request:user", + limit: @activation_request_limit, + interval: :timer.minutes(10) + }, + totp_setup_ip: %{ + prefix: "totp-setup:ip", + limit: 10, + interval: :timer.minutes(5) + }, + totp_setup_user: %{ + prefix: "totp-setup:user", + limit: 10, + interval: :timer.minutes(5) } } diff --git a/lib/plausible_web/controllers/auth_controller.ex b/lib/plausible_web/controllers/auth_controller.ex index 9fbd6237677d..5dc6c251343d 100644 --- a/lib/plausible_web/controllers/auth_controller.ex +++ b/lib/plausible_web/controllers/auth_controller.ex @@ -123,6 +123,20 @@ defmodule PlausibleWeb.AuthController do def activate(conn, %{"code" => code}) do user = conn.assigns[:current_user] + with :ok <- Auth.rate_limit(:activation_ip, conn), + :ok <- Auth.rate_limit(:activation_user, user) do + do_activate(conn, user, code) + else + {:error, {:rate_limit, _}} -> + render_error( + conn, + 429, + "Too many activation attempts. Wait a few minutes before trying again." + ) + end + end + + defp do_activate(conn, user, code) do has_any_invitations? = Plausible.Teams.Users.has_sites?(user, include_pending?: true) has_any_memberships? = Plausible.Teams.Users.has_sites?(user, include_pending?: false) @@ -167,11 +181,20 @@ defmodule PlausibleWeb.AuthController do def request_activation_code(conn, _params) do user = conn.assigns.current_user - Auth.EmailVerification.issue_code(user) - conn - |> put_flash(:success, "Activation code was sent to #{user.email}") - |> redirect(to: Routes.auth_path(conn, :activate_form)) + with :ok <- Auth.rate_limit(:activation_request_ip, conn), + :ok <- Auth.rate_limit(:activation_request_user, user) do + Auth.EmailVerification.issue_code(user) + + conn + |> put_flash(:success, "Activation code was sent to #{user.email}") + |> redirect(to: Routes.auth_path(conn, :activate_form)) + else + {:error, {:rate_limit, _}} -> + conn + |> put_flash(:error, "Too many code requests. Please wait before requesting another.") + |> redirect(to: Routes.auth_path(conn, :activate_form)) + end end def password_reset_request_form(conn, _) do @@ -396,11 +419,21 @@ defmodule PlausibleWeb.AuthController do end def verify_2fa_setup(conn, %{"code" => code}) do - case Auth.TOTP.enable(conn.assigns.current_user, code) do - {:ok, _, %{recovery_codes: codes}} -> - conn - |> put_flash(:success, "Two-Factor Authentication is fully enabled") - |> render("generate_2fa_recovery_codes.html", recovery_codes: codes, from_setup: true) + user = conn.assigns.current_user + + with :ok <- Auth.rate_limit(:totp_setup_ip, conn), + :ok <- Auth.rate_limit(:totp_setup_user, user), + {:ok, _, %{recovery_codes: codes}} <- Auth.TOTP.enable(user, code) do + conn + |> put_flash(:success, "Two-Factor Authentication is fully enabled") + |> render("generate_2fa_recovery_codes.html", recovery_codes: codes, from_setup: true) + else + {:error, {:rate_limit, _}} -> + render_error( + conn, + 429, + "Too many attempts. Wait a minute before trying again." + ) {:error, :invalid_code} -> conn diff --git a/test/plausible_web/controllers/auth_controller_test.exs b/test/plausible_web/controllers/auth_controller_test.exs index d3f76c99ad8f..501183e41ce3 100644 --- a/test/plausible_web/controllers/auth_controller_test.exs +++ b/test/plausible_web/controllers/auth_controller_test.exs @@ -467,6 +467,26 @@ defmodule PlausibleWeb.AuthControllerTest do refute Repo.get_by(Auth.EmailActivationCode, user_id: user.id) end + + test "limits activation attempts to 10 per 5 minutes", %{conn: conn} do + conn = put_req_header(conn, "x-forwarded-for", "10.9.8.7") + + response = + eventually( + fn -> + Enum.each(1..10, fn _ -> + post(conn, "/activate", %{code: "1111"}) + end) + + conn = post(conn, "/activate", %{code: "1111"}) + + {conn.status == 429, conn} + end, + 500 + ) + + assert html_response(response, 429) =~ "Too many activation attempts" + end end describe "GET /login_form" do From 7196ef0e16f8aa57bb736ea23b24ef50e77fbed5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cenk=20K=C3=BCc=C3=BCk?= Date: Mon, 6 Apr 2026 23:22:10 +0100 Subject: [PATCH 2/7] Increase activation request limit for e2e_test environment --- lib/plausible/auth/auth.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/plausible/auth/auth.ex b/lib/plausible/auth/auth.ex index c4605bf20ad0..bf8cc8165f58 100644 --- a/lib/plausible/auth/auth.ex +++ b/lib/plausible/auth/auth.ex @@ -18,6 +18,7 @@ defmodule Plausible.Auth do if Mix.env() == :e2e_test do @ip_rate_limit 100_000 @user_rate_limit 100_000 + @activation_request_limit 100_000 else @ip_rate_limit 5 @user_rate_limit 5 From fc234f52a4cb028e8b6a0985ffdda727b4010076 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cenk=20K=C3=BCc=C3=BCk?= Date: Mon, 6 Apr 2026 23:34:25 +0100 Subject: [PATCH 3/7] Refactor activation request limit --- lib/plausible/auth/auth.ex | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/plausible/auth/auth.ex b/lib/plausible/auth/auth.ex index bf8cc8165f58..353965c98946 100644 --- a/lib/plausible/auth/auth.ex +++ b/lib/plausible/auth/auth.ex @@ -18,11 +18,15 @@ defmodule Plausible.Auth do if Mix.env() == :e2e_test do @ip_rate_limit 100_000 @user_rate_limit 100_000 - @activation_request_limit 100_000 else @ip_rate_limit 5 @user_rate_limit 5 - @activation_request_limit if(Mix.env() == :test, do: 100_000, else: 5) + end + + if Mix.env() in [:test, :e2e_test] do + @activation_request_limit 100_000 + else + @activation_request_limit 5 end @rate_limits %{ From 1fbe098816ae8a4b5f3ce532a0a8acadb5d8fb24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cenk=20K=C3=BCc=C3=BCk?= Date: Mon, 6 Apr 2026 23:48:49 +0100 Subject: [PATCH 4/7] Update activation request limit condition to include :ce_test environment --- lib/plausible/auth/auth.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/plausible/auth/auth.ex b/lib/plausible/auth/auth.ex index 353965c98946..b31b93097a0c 100644 --- a/lib/plausible/auth/auth.ex +++ b/lib/plausible/auth/auth.ex @@ -23,7 +23,7 @@ defmodule Plausible.Auth do @user_rate_limit 5 end - if Mix.env() in [:test, :e2e_test] do + if Mix.env() in [:test, :ce_test, :e2e_test] do @activation_request_limit 100_000 else @activation_request_limit 5 From 5d02f579cd72ff3eb8f4bdd02c8c18bb112e6051 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cenk=20K=C3=BCc=C3=BCk?= Date: Tue, 7 Apr 2026 00:04:22 +0100 Subject: [PATCH 5/7] Refactor rate limits for activation and TOTP setup in e2e_test environment --- lib/plausible/auth/auth.ex | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/plausible/auth/auth.ex b/lib/plausible/auth/auth.ex index b31b93097a0c..9245520da5ff 100644 --- a/lib/plausible/auth/auth.ex +++ b/lib/plausible/auth/auth.ex @@ -18,15 +18,15 @@ defmodule Plausible.Auth do if Mix.env() == :e2e_test do @ip_rate_limit 100_000 @user_rate_limit 100_000 + @activation_limit 100_000 + @activation_request_limit 100_000 + @totp_setup_limit 100_000 else @ip_rate_limit 5 @user_rate_limit 5 - end - - if Mix.env() in [:test, :ce_test, :e2e_test] do - @activation_request_limit 100_000 - else - @activation_request_limit 5 + @activation_limit 10 + @totp_setup_limit 10 + @activation_request_limit if(Mix.env() in [:test, :ce_test], do: 100_000, else: 5) end @rate_limits %{ @@ -52,12 +52,12 @@ defmodule Plausible.Auth do }, activation_ip: %{ prefix: "activation:ip", - limit: 10, + limit: @activation_limit, interval: :timer.minutes(5) }, activation_user: %{ prefix: "activation:user", - limit: 10, + limit: @activation_limit, interval: :timer.minutes(5) }, activation_request_ip: %{ @@ -72,12 +72,12 @@ defmodule Plausible.Auth do }, totp_setup_ip: %{ prefix: "totp-setup:ip", - limit: 10, + limit: @totp_setup_limit, interval: :timer.minutes(5) }, totp_setup_user: %{ prefix: "totp-setup:user", - limit: 10, + limit: @totp_setup_limit, interval: :timer.minutes(5) } } From a2d10d0cffe950321ff716152d4d4e366aacde0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cenk=20K=C3=BCc=C3=BCk?= Date: Tue, 7 Apr 2026 00:31:53 +0100 Subject: [PATCH 6/7] Reduce activation request limit interval from 10 minutes to 1 minute --- lib/plausible/auth/auth.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/plausible/auth/auth.ex b/lib/plausible/auth/auth.ex index 9245520da5ff..4263d0c26df0 100644 --- a/lib/plausible/auth/auth.ex +++ b/lib/plausible/auth/auth.ex @@ -63,7 +63,7 @@ defmodule Plausible.Auth do activation_request_ip: %{ prefix: "activation-request:ip", limit: @activation_request_limit, - interval: :timer.minutes(10) + interval: :timer.minutes(1) }, activation_request_user: %{ prefix: "activation-request:user", From 8696177a7ba2d68653b90cdd2d43a8c7440eafc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cenk=20K=C3=BCc=C3=BCk?= Date: Tue, 7 Apr 2026 11:16:35 +0100 Subject: [PATCH 7/7] Refactor rate limits and adjust limits and intervals for different environments --- lib/plausible/auth/auth.ex | 47 +++++++++++++++++++++++++------------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/lib/plausible/auth/auth.ex b/lib/plausible/auth/auth.ex index 4263d0c26df0..49587b483d67 100644 --- a/lib/plausible/auth/auth.ex +++ b/lib/plausible/auth/auth.ex @@ -15,18 +15,33 @@ defmodule Plausible.Auth do require Logger - if Mix.env() == :e2e_test do - @ip_rate_limit 100_000 - @user_rate_limit 100_000 - @activation_limit 100_000 - @activation_request_limit 100_000 - @totp_setup_limit 100_000 - else - @ip_rate_limit 5 - @user_rate_limit 5 - @activation_limit 10 - @totp_setup_limit 10 - @activation_request_limit if(Mix.env() in [:test, :ce_test], do: 100_000, else: 5) + case Mix.env() do + :e2e_test -> + @ip_rate_limit 100_000 + @user_rate_limit 100_000 + @activation_limit 100_000 + @activation_ip_limit 100_000 + @activation_request_limit 100_000 + @totp_setup_limit 100_000 + @totp_setup_ip_limit 100_000 + + env when env in [:test, :ce_test] -> + @ip_rate_limit 5 + @user_rate_limit 5 + @activation_limit 10 + @totp_setup_limit 10 + @activation_ip_limit 100_000 + @totp_setup_ip_limit 100_000 + @activation_request_limit 100_000 + + _ -> + @ip_rate_limit 5 + @user_rate_limit 5 + @activation_limit 10 + @totp_setup_limit 10 + @activation_ip_limit 2 + @totp_setup_ip_limit 2 + @activation_request_limit 5 end @rate_limits %{ @@ -52,8 +67,8 @@ defmodule Plausible.Auth do }, activation_ip: %{ prefix: "activation:ip", - limit: @activation_limit, - interval: :timer.minutes(5) + limit: @activation_ip_limit, + interval: :timer.minutes(1) }, activation_user: %{ prefix: "activation:user", @@ -72,8 +87,8 @@ defmodule Plausible.Auth do }, totp_setup_ip: %{ prefix: "totp-setup:ip", - limit: @totp_setup_limit, - interval: :timer.minutes(5) + limit: @totp_setup_ip_limit, + interval: :timer.minutes(1) }, totp_setup_user: %{ prefix: "totp-setup:user",