neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Rate limits: wrong passwords, sign-in emails, sign-ups and runner polls
3d1b0af · Gabriel Morell · 2026-10-20 20:21
Message
{commit_body(@commit)}
Files changed
modified
README.md
+2
−0
| 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 | 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 | 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
| 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 | 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 | 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 | 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 | 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 | 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