Skip to content

Commit 7c64ceb

Browse files
committed
feat: ip banning
1 parent 4edf65a commit 7c64ceb

9 files changed

Lines changed: 446 additions & 10 deletions

File tree

‎README.md‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,19 @@ Traefik/Authelia middleware is not required. Built-in auth is enabled by default
265265
set `MANAVAULT_ADMIN_PASSWORD_HASH`, or explicitly opt out with
266266
`MANAVAULT_AUTH_DISABLED=true`. Keep static assets and share links public at the
267267
proxy; ManaVault protects the private app routes and `/api/graphql` with its own
268-
session cookie:
268+
session cookie.
269+
270+
The login endpoint also enforces in-app failed-password defenses before
271+
checking the password hash: 5 failures per client IP and 30 failures globally
272+
per 15-minute window by default. A client IP is permanently blocked after 30
273+
failed password checks. This is not a replacement for fail2ban or proxy-level
274+
throttling, but it keeps brute-force protection with the app when the reverse
275+
proxy is simple or misconfigured.
276+
277+
Permanent means no automatic expiry: to unblock a client, delete its row from
278+
`auth_client_failures` in the ManaVault SQLite database.
279+
280+
A minimal Traefik config can stay simple:
269281

270282
```yaml
271283
labels:
@@ -313,6 +325,15 @@ Common optional values:
313325
Generate with `mise exec -- mix manavault.auth.hash 'your-password'`.
314326
- `MANAVAULT_AUTH_DISABLED` - set to `true` only when another layer already
315327
protects ManaVault and you want to opt out of built-in auth.
328+
- `MANAVAULT_AUTH_MAX_ATTEMPTS_PER_IP` - failed login attempts allowed per
329+
client IP during the rate-limit window. Defaults to `5`.
330+
- `MANAVAULT_AUTH_MAX_ATTEMPTS_GLOBAL` - failed login attempts allowed across
331+
all clients during the rate-limit window. Defaults to `30`.
332+
- `MANAVAULT_AUTH_PERMANENT_BAN_AFTER_FAILURES` - cumulative failed login
333+
attempts from one client IP before ManaVault permanently blocks that client.
334+
Defaults to `30`.
335+
- `MANAVAULT_AUTH_RATE_LIMIT_WINDOW_SECONDS` - failed login rate-limit window.
336+
Defaults to `900`.
316337
- `DATA_DIR` - mutable data root. Defaults to `/data`.
317338
- `DATABASE_PATH` - SQLite database path. Defaults to `/data/manavault.db`.
318339
- `POOL_SIZE` - Ecto pool size. Defaults to `5`.

‎config/config.exs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ import Config
1010
config :manavault,
1111
admin_password_hash: nil,
1212
auth_disabled: false,
13+
auth_rate_limit: [
14+
window_ms: :timer.minutes(15),
15+
max_attempts_per_ip: 5,
16+
max_attempts_global: 30,
17+
permanent_ban_after_failures: 30
18+
],
1319
ecto_repos: [Manavault.Repo],
1420
generators: [timestamp_type: :utc_datetime]
1521

‎config/runtime.exs‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,21 @@ config :manavault, ManavaultWeb.Endpoint, http: [ip: {0, 0, 0, 0}, port: port]
4444
{admin_password_hash, auth_disabled}
4545
end
4646

47+
auth_rate_limit = [
48+
window_ms:
49+
String.to_integer(System.get_env("MANAVAULT_AUTH_RATE_LIMIT_WINDOW_SECONDS", "900")) * 1000,
50+
max_attempts_per_ip:
51+
String.to_integer(System.get_env("MANAVAULT_AUTH_MAX_ATTEMPTS_PER_IP", "5")),
52+
max_attempts_global:
53+
String.to_integer(System.get_env("MANAVAULT_AUTH_MAX_ATTEMPTS_GLOBAL", "30")),
54+
permanent_ban_after_failures:
55+
String.to_integer(System.get_env("MANAVAULT_AUTH_PERMANENT_BAN_AFTER_FAILURES", "30"))
56+
]
57+
4758
config :manavault,
4859
admin_password_hash: admin_password_hash,
49-
auth_disabled: auth_disabled
60+
auth_disabled: auth_disabled,
61+
auth_rate_limit: auth_rate_limit
5062

5163
if config_env() == :prod do
5264
if !auth_disabled && is_nil(admin_password_hash) do

‎lib/manavault/application.ex‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ defmodule Manavault.Application do
1515
{Manavault.Backup.MigrationBackup, repo: Manavault.Repo},
1616
{Ecto.Migrator,
1717
repos: Application.fetch_env!(:manavault, :ecto_repos), skip: skip_migrations?()},
18+
Manavault.Auth.AttemptLimiter,
1819
{DNSCluster, query: Application.get_env(:manavault, :dns_cluster_query) || :ignore},
1920
{Phoenix.PubSub, name: Manavault.PubSub},
2021
{Task.Supervisor, name: Manavault.Backup.TaskSupervisor},
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
defmodule Manavault.Auth.AttemptLimiter do
2+
@moduledoc false
3+
4+
use GenServer
5+
6+
import Ecto.Query
7+
8+
alias Manavault.Auth.ClientFailure
9+
alias Manavault.Repo
10+
11+
@default_window_ms :timer.minutes(15)
12+
@default_max_attempts_per_ip 5
13+
@default_max_attempts_global 30
14+
@default_permanent_ban_after_failures 30
15+
@global_key :global
16+
17+
def start_link(opts \\ []) do
18+
name = Keyword.get(opts, :name, __MODULE__)
19+
GenServer.start_link(__MODULE__, %{}, name: name)
20+
end
21+
22+
def check(client_id) do
23+
GenServer.call(__MODULE__, {:check, client_id})
24+
end
25+
26+
def record_failure(client_id) do
27+
GenServer.call(__MODULE__, {:record_failure, client_id})
28+
end
29+
30+
def reset(client_id) do
31+
GenServer.call(__MODULE__, {:reset, client_id})
32+
end
33+
34+
def reset_all do
35+
GenServer.call(__MODULE__, :reset_all)
36+
end
37+
38+
@impl true
39+
def init(state), do: {:ok, state}
40+
41+
@impl true
42+
def handle_call({:check, client_id}, _from, attempts) do
43+
now = now_ms()
44+
attempts = prune_expired(attempts, now)
45+
46+
cond do
47+
permanently_banned?(client_id) ->
48+
{:reply, :permanently_banned, attempts}
49+
50+
retry_after = retry_after(attempts, client_id, now) ->
51+
{:reply, {:rate_limited, retry_after}, attempts}
52+
53+
true ->
54+
{:reply, :ok, attempts}
55+
end
56+
end
57+
58+
def handle_call({:record_failure, client_id}, _from, attempts) do
59+
now = now_ms()
60+
61+
attempts =
62+
attempts
63+
|> prune_expired(now)
64+
|> increment({:client, client_id}, now)
65+
|> increment(@global_key, now)
66+
67+
{:reply, record_persistent_failure(client_id), attempts}
68+
end
69+
70+
def handle_call({:reset, client_id}, _from, attempts) do
71+
delete_persistent_failure(client_id)
72+
73+
{:reply, :ok, Map.delete(attempts, {:client, client_id})}
74+
end
75+
76+
def handle_call(:reset_all, _from, _attempts) do
77+
Repo.delete_all(ClientFailure)
78+
79+
{:reply, :ok, %{}}
80+
end
81+
82+
defp retry_after(attempts, client_id, now) do
83+
attempts
84+
|> Enum.reduce(nil, fn
85+
{{:client, ^client_id}, attempt}, retry_after ->
86+
retry_after_if_limited(attempt, :per_ip, now, retry_after)
87+
88+
{@global_key, attempt}, retry_after ->
89+
retry_after_if_limited(attempt, :global, now, retry_after)
90+
91+
_entry, retry_after ->
92+
retry_after
93+
end)
94+
end
95+
96+
defp retry_after_if_limited(%{count: count, expires_at: expires_at}, scope, now, retry_after) do
97+
if count >= limit(scope) do
98+
seconds = max(1, ceil((expires_at - now) / 1000))
99+
max(retry_after || 0, seconds)
100+
else
101+
retry_after
102+
end
103+
end
104+
105+
defp increment(attempts, key, now) do
106+
Map.update(attempts, key, fresh_attempt(now), fn attempt ->
107+
%{attempt | count: attempt.count + 1}
108+
end)
109+
end
110+
111+
defp fresh_attempt(now) do
112+
%{count: 1, expires_at: now + window_ms()}
113+
end
114+
115+
defp prune_expired(attempts, now) do
116+
Map.reject(attempts, fn {_key, %{expires_at: expires_at}} -> expires_at <= now end)
117+
end
118+
119+
defp permanently_banned?(client_id) do
120+
match?(
121+
%ClientFailure{banned_at: %DateTime{}},
122+
Repo.get_by(ClientFailure, client_id: client_id)
123+
)
124+
end
125+
126+
defp record_persistent_failure(client_id) do
127+
client_failure =
128+
Repo.get_by(ClientFailure, client_id: client_id) || %ClientFailure{client_id: client_id}
129+
130+
failed_attempts = client_failure.failed_attempts + 1
131+
132+
client_failure
133+
|> ClientFailure.changeset(%{
134+
failed_attempts: failed_attempts,
135+
banned_at: banned_at(failed_attempts)
136+
})
137+
|> Repo.insert_or_update!()
138+
139+
if failed_attempts >= permanent_ban_after_failures(), do: :banned, else: :ok
140+
end
141+
142+
defp banned_at(failed_attempts) do
143+
if failed_attempts >= permanent_ban_after_failures() do
144+
DateTime.utc_now() |> DateTime.truncate(:second)
145+
end
146+
end
147+
148+
defp delete_persistent_failure(client_id) do
149+
from(failure in ClientFailure, where: failure.client_id == ^client_id)
150+
|> Repo.delete_all()
151+
end
152+
153+
defp now_ms, do: System.monotonic_time(:millisecond)
154+
155+
defp limit(:per_ip), do: auth_rate_limit(:max_attempts_per_ip, @default_max_attempts_per_ip)
156+
defp limit(:global), do: auth_rate_limit(:max_attempts_global, @default_max_attempts_global)
157+
defp window_ms, do: auth_rate_limit(:window_ms, @default_window_ms)
158+
159+
defp permanent_ban_after_failures do
160+
auth_rate_limit(:permanent_ban_after_failures, @default_permanent_ban_after_failures)
161+
end
162+
163+
defp auth_rate_limit(key, default) do
164+
:manavault
165+
|> Application.get_env(:auth_rate_limit, [])
166+
|> Keyword.get(key, default)
167+
end
168+
end
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
defmodule Manavault.Auth.ClientFailure do
2+
@moduledoc false
3+
4+
use Ecto.Schema
5+
6+
import Ecto.Changeset
7+
8+
schema "auth_client_failures" do
9+
field :client_id, :string
10+
field :failed_attempts, :integer, default: 0
11+
field :banned_at, :utc_datetime
12+
13+
timestamps(type: :utc_datetime)
14+
end
15+
16+
def changeset(client_failure, attrs) do
17+
client_failure
18+
|> cast(attrs, [:client_id, :failed_attempts, :banned_at])
19+
|> validate_required([:client_id, :failed_attempts])
20+
|> validate_number(:failed_attempts, greater_than_or_equal_to: 0)
21+
|> unique_constraint(:client_id)
22+
end
23+
end

‎lib/manavault_web/controllers/auth_controller.ex‎

Lines changed: 72 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ defmodule ManavaultWeb.AuthController do
22
use ManavaultWeb, :controller
33

44
alias Manavault.Auth
5+
alias Manavault.Auth.AttemptLimiter
56
alias ManavaultWeb.Plugs.Authentication
67

78
def new(conn, params) do
@@ -39,15 +40,8 @@ defmodule ManavaultWeb.AuthController do
3940
|> put_resp_content_type("text/html")
4041
|> send_resp(503, login_html(get_csrf_token(), return_to, missing_hash_message()))
4142

42-
Auth.verify_admin_password(password) ->
43-
conn
44-
|> Authentication.sign_in()
45-
|> redirect(to: return_to)
46-
4743
true ->
48-
conn
49-
|> put_resp_content_type("text/html")
50-
|> send_resp(401, login_html(get_csrf_token(), return_to, "Incorrect password"))
44+
handle_password_login(conn, password, return_to)
5145
end
5246
end
5347

@@ -63,6 +57,76 @@ defmodule ManavaultWeb.AuthController do
6357
|> redirect(to: "/login")
6458
end
6559

60+
defp handle_password_login(conn, password, return_to) do
61+
client_id = client_id(conn)
62+
63+
case AttemptLimiter.check(client_id) do
64+
:permanently_banned ->
65+
permanently_banned_response(conn, return_to)
66+
67+
{:rate_limited, retry_after} ->
68+
rate_limited_response(conn, return_to, retry_after)
69+
70+
:ok ->
71+
verify_password_login(conn, password, return_to, client_id)
72+
end
73+
end
74+
75+
defp verify_password_login(conn, password, return_to, client_id) do
76+
if Auth.verify_admin_password(password) do
77+
AttemptLimiter.reset(client_id)
78+
79+
conn
80+
|> Authentication.sign_in()
81+
|> redirect(to: return_to)
82+
else
83+
case AttemptLimiter.record_failure(client_id) do
84+
:banned -> permanently_banned_response(conn, return_to)
85+
:ok -> incorrect_password_response(conn, return_to)
86+
end
87+
end
88+
end
89+
90+
defp incorrect_password_response(conn, return_to) do
91+
conn
92+
|> put_resp_content_type("text/html")
93+
|> send_resp(401, login_html(get_csrf_token(), return_to, "Incorrect password"))
94+
end
95+
96+
defp permanently_banned_response(conn, return_to) do
97+
conn
98+
|> put_resp_content_type("text/html")
99+
|> send_resp(403, login_html(get_csrf_token(), return_to, permanently_banned_message()))
100+
end
101+
102+
defp permanently_banned_message do
103+
"Too many incorrect password attempts. This client is permanently blocked."
104+
end
105+
106+
defp rate_limited_response(conn, return_to, retry_after) do
107+
conn
108+
|> put_resp_header("retry-after", Integer.to_string(retry_after))
109+
|> put_resp_content_type("text/html")
110+
|> send_resp(429, login_html(get_csrf_token(), return_to, rate_limited_message(retry_after)))
111+
end
112+
113+
defp rate_limited_message(retry_after) when retry_after < 120 do
114+
"Too many incorrect password attempts. Try again in #{retry_after} seconds."
115+
end
116+
117+
defp rate_limited_message(retry_after) do
118+
minutes = retry_after |> Kernel./(60) |> ceil()
119+
"Too many incorrect password attempts. Try again in #{minutes} minutes."
120+
end
121+
122+
defp client_id(%{remote_ip: remote_ip}) when is_tuple(remote_ip) do
123+
remote_ip
124+
|> :inet.ntoa()
125+
|> to_string()
126+
end
127+
128+
defp client_id(_conn), do: "unknown"
129+
66130
defp safe_return_to(path) when is_binary(path) do
67131
cond do
68132
path == "" -> "/"
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
defmodule Manavault.Repo.Migrations.CreateAuthClientFailures do
2+
use Ecto.Migration
3+
4+
def change do
5+
create table(:auth_client_failures) do
6+
add :client_id, :text, null: false
7+
add :failed_attempts, :integer, null: false, default: 0
8+
add :banned_at, :utc_datetime
9+
10+
timestamps(type: :utc_datetime)
11+
end
12+
13+
create unique_index(:auth_client_failures, [:client_id])
14+
create index(:auth_client_failures, [:banned_at])
15+
end
16+
end

0 commit comments

Comments
 (0)