Rate limits: wrong passwords, sign-in emails, sign-ups and runner polls

3d1b0af · Gabriel Morell · 2026-10-20 20:21

30 files +866 -36
Message
{commit_body(@commit)}

Files changed

modified README.md
+2 −0
@@ -253,6 +253,7 @@ These all live in `config/*.exs`. Key ones:
253 253 | `GITGUD_HOOK_SECRET` (env) | Shared secret the git hooks and the SSH shim send (`x-gitgud-hook-token`) to `/_hooks/*` and `/_ssh/resolve`; anything else gets a 403. The node mints one at boot and hands it to every `git` it spawns, so the embedded SSH daemon and smart HTTP need nothing. **Set it, to the same value for both, when using the OpenSSH `authorized_keys` shim** (`gitgud-ssh`, from `mix gitgud.ssh.authorized_keys`), which runs under sshd rather than the node | random per boot |
254 254 | `GITGUD_HOOK_URL` / `GITGUD_PREHOOK_URL` / `GITGUD_AUTH_URL` (env, hook side) | Where `gitgud-hook`, `gitgud-prereceive` and `gitgud-ssh` reach the node | `http://127.0.0.1:4000/_hooks/post-receive` / `…/_hooks/pre-receive` / `…/_ssh/resolve` |
255 255 | `GITGUD_PREHOOK_FAIL_OPEN` (env, hook side) | `1` lets a push through when the pre-receive (branch protection) backend is unreachable — curl missing, connection refused or timed out — or answers 5xx. Never on a 4xx such as a 403 from a missing or wrong hook secret, and never when `GITGUD_HOOK_SECRET` is unset. The node logs a warning at boot while it is set | unset (fail closed) |
256 +| `GITGUD_TRUSTED_PROXIES` | Comma-separated CIDRs of reverse proxies whose `X-Forwarded-For` is believed. The client address (for rate limits and logs) is the first hop, from the right, that isn't one of them | loopback, `10/8`, `172.16/12`, `192.168/16`, `fc00::/7` |
256 257 | `GITGUD_IMPORT_ALLOW_PRIVATE` (env) | `1`/`true`/`yes` lets imports, mirrors and upstream sync reach private, loopback and link-local addresses (`GitGud.OutboundUrl` `allow_private: [:import]`). The scheme rule and the no-local-paths rule still apply | unset (public addresses only) |
257 258 | `GITGUD_WEBHOOK_ALLOW_PRIVATE` (env) | The same for webhook delivery (`:webhook`). Webhooks pointing at a LAN host stop delivering until this is set | unset |
258 259 | `GITGUD_FEDERATION_ALLOW_PRIVATE` (env) | The same for ActivityPub actor fetches and inbox delivery (`:federation`) | unset |
@@ -298,6 +299,7 @@ addresses, follows no redirects and pins the address it checked.
298 299 Behaviour that changed under an existing install, as of the October
299 300 2026 hardening (slice 78):
300 301
302 +- **Sign-ins, sign-in emails, sign-ups and runner polls are rate limited** (`GitGud.RateLimit`, per node). Ten wrong passwords per account or fifty per client address in 15 minutes lock out password sign-in — on the login form and Basic auth for git (answered 429) and the package registries — until the window passes; email links still work. Five sign-in emails per address and twenty per client an hour; ten sign-ups per client an hour; twenty bad runner registration tokens per client in 15 minutes; a runner polling FetchTask more than 120 times a minute is told there is no work. Tokens (`ggp_…`, job tokens) are not limited. Behind a proxy outside the private ranges, set `GITGUD_TRUSTED_PROXIES`, or every client shares the proxy's limit.
301 303 - **Runner registration tokens are single-use and expire.** A repository or organization registration token now registers one runner and lapses after 24 hours (`registration_token_ttl_seconds`); tokens minted before the upgrade lapse a day after its migration. The instance-wide `GITGUD_RUNNER_REG_TOKEN` stays reusable, for pools.
302 304 - **Only writers' reviews count.** Approvals and "changes requested"
303 305 from someone without write access to the target no longer count
modified config/runtime.exs
+7 −0
@@ -175,6 +175,13 @@ if config_env() == :prod do
175 175 config :git_gud, :default_actions_url, actions_url
176 176 end
177 177
178 + # Proxies whose X-Forwarded-For is believed (comma-separated CIDRs).
179 + # Unset -> loopback and the private ranges; see GitGudWeb.Plugs.ClientIp.
180 + if proxies = System.get_env("GITGUD_TRUSTED_PROXIES") do
181 + config :git_gud, GitGudWeb.Plugs.ClientIp,
182 + trusted_proxies: proxies |> String.split(",", trim: true) |> Enum.map(&String.trim/1)
183 + end
184 +
178 185 # Sentry error tracking. Unset -> Sentry no-ops.
179 186 config :sentry, dsn: System.get_env("SENTRY_DSN")
180 187
added lib/git_gud/accounts/password_throttle.ex
+60 −0
@@ -0,0 +1,60 @@
1 +defmodule GitGud.Accounts.PasswordThrottle do
2 + @moduledoc """
3 + Password sign-in with a cap on wrong guesses, for every place a
4 + password is accepted: the login form and Basic auth for git and the
5 + package registries.
6 +
7 + Two counts, both bumped by every wrong password and checked before
8 + the password is tried:
9 +
10 + * per login (the email or handle as typed, case-folded) — ten in
11 + 15 minutes, so one account can't be guessed at however many
12 + addresses the guesses come from;
13 + * per client address — fifty in 15 minutes, so one client can't
14 + walk a list of accounts.
15 +
16 + Counted whether or not the account exists, so being refused says
17 + nothing about whether it does. A right password clears its login's
18 + count. Limits live in `GitGud.RateLimit`.
19 + """
20 +
21 + alias GitGud.Accounts
22 + alias GitGud.Accounts.User
23 + alias GitGud.RateLimit
24 +
25 + require Logger
26 +
27 + @doc """
28 + `{:ok, user}` for the right password, `:error` for a wrong one or an
29 + unknown login, `{:error, :rate_limited, retry_after_s}` when this
30 + login or address has run out of guesses (the password isn't tried).
31 + """
32 + @spec authenticate(String.t(), String.t(), :inet.ip_address() | nil) ::
33 + {:ok, User.t()} | :error | {:error, :rate_limited, pos_integer()}
34 + def authenticate(login, password, ip) when is_binary(login) and is_binary(password) do
35 + login_key = normalize(login)
36 + ip_key = RateLimit.ip_key(ip)
37 +
38 + with :ok <- RateLimit.check(:login_account, login_key),
39 + :ok <- RateLimit.check(:login_ip, ip_key) do
40 + case Accounts.get_user_by_email_and_password(login, password) do
41 + %User{} = user ->
42 + RateLimit.clear(:login_account, login_key)
43 + {:ok, user}
44 +
45 + _ ->
46 + RateLimit.record(:login_account, login_key)
47 + RateLimit.record(:login_ip, ip_key)
48 + :error
49 + end
50 + else
51 + {:error, retry_after} ->
52 + Logger.warning("password sign-in refused for #{ip_key}: too many wrong passwords")
53 + {:error, :rate_limited, retry_after}
54 + end
55 + end
56 +
57 + def authenticate(_login, _password, _ip), do: :error
58 +
59 + defp normalize(login), do: login |> String.trim() |> String.downcase()
60 +end
modified lib/git_gud/application.ex
+2 −0
@@ -40,6 +40,8 @@ defmodule GitGud.Application do
40 40 GitGud.Federation.RateLimiter,
41 41 # Failed TOTP / recovery-code counters for the 2FA challenge.
42 42 GitGud.Accounts.TwoFactorAttempts,
43 + # Failed sign-ins, emails, sign-ups and runner polls per key.
44 + GitGud.RateLimit,
43 45 # Holds (and renews) the OpenBao token the F-Droid signer
44 46 # uses. Idle unless OpenBao is configured.
45 47 GitGud.Packages.Signing.OpenBao.Session,
modified lib/git_gud/packages/auth.ex
+8 −5
@@ -37,6 +37,7 @@ defmodule GitGud.Packages.Auth do
37 37 """
38 38
39 39 alias GitGud.Accounts
40 + alias GitGud.Accounts.PasswordThrottle
40 41 alias GitGud.Accounts.User
41 42 alias GitGud.Packages
42 43 alias GitGud.Packages.Access
@@ -64,7 +65,7 @@ defmodule GitGud.Packages.Auth do
64 65 def authenticate(conn) do
65 66 conn
66 67 |> credentials()
67 − |> Enum.find_value(:anonymous, &verify/1)
68 + |> Enum.find_value(:anonymous, &verify(&1, conn.remote_ip))
68 69 end
69 70
70 71 # Every credential shape the request might be carrying, most specific
@@ -103,17 +104,17 @@ defmodule GitGud.Packages.Auth do
103 104 end
104 105 end
105 106
106 − defp verify({username, secret}) do
107 + defp verify({username, secret}, ip) do
107 108 case Packages.verify_registry_token(secret) do
108 109 {:ok, repo_id, scopes} ->
109 110 {:token, repo_id, scopes}
110 111
111 112 :error ->
112 − verify_job_or_user(username, secret)
113 + verify_job_or_user(username, secret, ip)
113 114 end
114 115 end
115 116
116 − defp verify_job_or_user(username, secret) do
117 + defp verify_job_or_user(username, secret, ip) do
117 118 case RunnerJwt.verify_active(secret) do
118 119 {:ok, %{"pid" => repo_id} = claims} when is_integer(repo_id) ->
119 120 {:job, repo_id, claims["perm"]}
@@ -125,9 +126,11 @@ defmodule GitGud.Packages.Auth do
125 126 {:ok, %User{} = user, scopes} ->
126 127 {:user, user, scopes}
127 128
129 + # A password counts against `PasswordThrottle`; once out of
130 + # guesses the caller is anonymous, wrong password or not.
128 131 :error ->
129 132 with true <- is_binary(username),
130 − %User{} = user <- Accounts.get_user_by_email_and_password(username, secret) do
133 + {:ok, user} <- PasswordThrottle.authenticate(username, secret, ip) do
131 134 {:user, user, :all}
132 135 else
133 136 _ -> nil
added lib/git_gud/rate_limit.ex
+179 −0

Click to load diff…

modified lib/git_gud_web/controllers/runner_controller.ex
+54 −9

Click to load diff…

modified lib/git_gud_web/controllers/user_session_controller.ex
+31 −10

Click to load diff…

modified lib/git_gud_web/endpoint.ex
+6 −2

Click to load diff…

modified lib/git_gud_web/live/user_live/login.ex
+26 −2

Click to load diff…

modified lib/git_gud_web/live/user_live/registration.ex
+31 −5

Click to load diff…

added lib/git_gud_web/plugs/client_ip.ex
+139 −0

Click to load diff…

modified lib/git_gud_web/plugs/git_smart_http.ex
+20 −3

Click to load diff…

added test/git_gud/rate_limit_test.exs
+54 −0

Click to load diff…

added test/git_gud_web/plugs/client_ip_test.exs
+45 −0

Click to load diff…

added test/git_gud_web/rate_limits_test.exs
+202 −0

Click to load diff…

Parents: 67bd0f9