Skip to content

fix: limit failed logins per source address and bound bcrypt verification - #718

Open
LeeroyHannigan wants to merge 17 commits into
mainfrom
fix/login-rate-limit
Open

LeeroyHannigan wants to merge 17 commits into
mainfrom
fix/login-rate-limit

Conversation

@LeeroyHannigan

@LeeroyHannigan LeeroyHannigan commented Nov 1, 2026 •

Copy link
Copy Markdown
Collaborator

What

Login limits are now per source address, and an attempt reserves its slot before the password is checked.

Limits (values unchanged):

limit value
failures per principal and source address 5 in 15 minutes
failures per source address, all principals 20 in 15 minutes

RateLimitStore::count_principal_failures takes the source address and counts only that (principal, address) pair. All four backends (SQLite, PostgreSQL, Cassandra, MongoDB) filter on the source_ip column they already stored.

Every management handler now takes the connection's peer address through axum's ConnectInfo extractor and passes it to authenticate and authenticate_admin, which take a SocketAddr instead of Option<&str>. The console login and /metrics use the same path. The limiter counts an IPv4 peer, and an IPv6 peer carrying an IPv4-mapped address, under the IPv4 address. Any other IPv6 peer counts under its /64 prefix, because a client can send from any address in the /64 it holds and would otherwise get a fresh set of limits for every address it picks. aws:SourceIp still sees the full address.

rate_limit::begin_login replaces check_login_allowed and record_failed_login. It increments an in-process count of attempts in flight for the pair and for the address, then reads the two recorded counts, and admits the attempt only if recorded plus in-flight is within both limits. The returned LoginAttempt holds the reservation; record_failure writes the row and then releases it, and dropping the attempt (success, storage error, cancelled request) releases it without a row. An earlier attempt is therefore visible to a concurrent admission check either in flight or in the table, and a burst is admitted up to the cap with the rest answered 429 before any password check. A principal longer than 256 bytes is answered as a wrong password before the store is read or anything is reserved; admin names are at most 64 bytes and account_id/user_name at most 141.

bcrypt verification moves into extenddb_storage::password::verify_password and the server's hashing into extenddb_storage::password::hash_password. Both take a permit from one process-wide semaphore of four slots and move it into the blocking bcrypt call, which releases it when bcrypt returns. A request cancelled while bcrypt runs, such as a client that disconnects mid-login, keeps its slot until bcrypt finishes, so hashing and verification together never run more than four bcrypt calls at once per process. All eight verify sites in the four backends call verify_password. A verification that waits more than 10 seconds for a slot is answered 500 and not recorded as a failure, since no password was checked; the storage crate logs it once at WARN, and the handlers no longer log it again as a database error. Hashing waits without a timeout, as before.

Waits added by this change:

  • bcrypt slot for a verification: at most 10 seconds, then 500 with nothing recorded.
  • bcrypt slot for a hash: no timeout (unchanged). Hashes share the four slots with verifications, so a burst of password changes can make a concurrent login wait its 10 seconds and then answer 500.
  • the in-flight registry's mutex: held for a map update only, never across an await.

Source address and shared addresses

The source is the peer address of the TCP connection. ExtendDB does not read X-Forwarded-For or any other forwarded-address header; they are caller-supplied. This is the existing policy for aws:SourceIp and is unchanged. Callers that reach ExtendDB from one address share one set of limits: five failures for a user from any of them, or twenty in total, refuse that user or every user to all of them for fifteen minutes. This covers callers behind a reverse proxy or load balancer that opens its own connections to ExtendDB, extenddb manage and a /metrics scraper running on the server host, and clients behind one NAT gateway. The security model manual lists these cases, advises putting a proxy or load balancer in front of ExtendDB only when it preserves the client address or when callers behind it are trusted not to lock each other out, and gives the recovery: wait fifteen minutes, or delete the address's rows from login_attempts.

A password can still be guessed against one principal from many addresses at five attempts per address per fifteen minutes; the per-address limit and the password's strength are the bounds on that. A legitimate user is no longer affected by anyone else's failures; their own address is refused for a principal after five wrong passwords from it, for fifteen minutes, as before.

Docs

docs/manuals/10-security-model.md gains a Login Rate Limiting section with the limits, the reservation, the bcrypt bound, the source address rule with the /64 prefix for IPv6, and the callers that share an address (a proxy or load balancer, the server host, a NAT gateway) with the recovery. The architecture guide, the storage design doc, the storage extension manual (whose method table listed methods the trait never had) and the deployment checklist's reverse-proxy line are updated, and the docs and comments that called the limits account lockout now say login rate limiting per user and source address.

Not covered by this change

Merging with #704

#704 (console session revocation) verifies console passwords in its own verify_login in crates/server/src/console/pages/auth_pages.rs, which takes no bcrypt slot. The two changes conflict in crates/server/src/console/pages/auth_pages.rs and crates/server/src/management/password.rs. Whichever of the two merges second:

  1. Routes verify_login through extenddb_storage::password::verify_password, answering its error with a 500 that records nothing, so console logins stay within the four-slot bound.
  2. Removes fix(console): end console sessions when the principal is deleted or its password changes #704's crate::management::password::verify_password, which runs bcrypt::verify on the blocking pool with no bound and has no caller once verify_login is rerouted.
  3. Carries fix(console): end console sessions when the principal is deleted or its password changes #704's CredentialStamp through this change's check_credentials and settle_attempt, which return the identity only, and calls state.sessions.create(identity, stamp).
  4. Gives the two console admin-check tests in auth_pages.rs a PasswordHashStore fake in place of FailingAdmins, an AdminStore fake, because fix(console): end console sessions when the principal is deleted or its password changes #704's try_admin_login takes &dyn PasswordHashStore. The console bcrypt-timeout test can then reach the log helper only through the store read, because the overload arises inside verify_login; the management test a_bcrypt_slot_timeout_answers_500_and_is_not_logged_as_a_store_error still covers the helper.

A trial merge done this way passed the server crate's 270 tests, and tests/test_rate_limiting.py with tests/test_console_session_revocation.py against a SQLite build of it passed 10, with 1 skipped that needs a second server.

Why

On main, the limit of five failed logins for a principal counted that principal's failures from every address. The management API handlers passed no source address to the limiter, so the limit of 20 failures per address applied only to the console login and to role assumption. Five wrong passwords for a known admin name, sent from anywhere, made ExtendDB answer 429 Too Many Requests to that admin for fifteen minutes on every surface, even with the correct password, and the sender could repeat this indefinitely.

The limit was also checked before the password was verified and the failure recorded after, with no bound on how many bcrypt verifications ran at once (the server's semaphore covered hashing only). A burst of concurrent wrong passwords ran one verification each, past the cap of five.

Reproduction on main, against a SQLite server with the default admin user:

  1. Send 80 concurrent GET /management/admins with Authorization: Basic admin:<wrong password>.
    Observed: 401 for all 80, 80 rows in login_attempts, and the next request with the correct password from any address answers 429.
  2. From address A, send five wrong passwords for admin. From address B, send the correct password.
    Observed: 429 from B.

Testing done

Tests

Unit, crates/storage/src/password.rs: the right password is accepted and a wrong one refused; a malformed hash counts as a wrong password and returns no error; a hash verifies against its password; 32 concurrent verifications on 8 worker threads never run more than 4 at once (high-water mark); four verifications, and separately four hashes, whose callers are cancelled while bcrypt runs keep their slots, so four verifications started after the cancellation never push the high-water mark past 4. On a slow host a queued check in these tests may be refused as overloaded after its 10-second wait; the high-water mark is checked either way.

Unit, crates/server/src/rate_limit/tests.rs, with an in-memory attempts store: five failures from A refuse A for that principal and B is admitted with no row written; A is still admitted for another principal; 20 failures over 20 names from one address refuse the 21st name there and not elsewhere; 40 concurrent attempts against a store with a 50 ms read delay admit exactly 5, with 5 in flight and 0 rows, and after they are recorded the next attempt is refused and the registry entry is gone; an admission made while a failure's row write is held open is refused; a dropped attempt leaves no row and no reservation; a store error answers Internal and holds no reservation; [::ffff:198.51.100.60] shares the limit of 198.51.100.60; an IPv6 peer is keyed by its /64, and addresses in one /64 share the pair and per-address limits while the next /64 does not; a principal of 257 bytes is refused before the store is read and one of 256 bytes reaches the store; the refusal log line escapes a newline in the principal.

Unit, crates/server/src/management/auth.rs and crates/server/src/console/pages/auth_pages.rs: each surface answers a refused attempt with 429 for a limit, 500 when the attempts store cannot be read, and its wrong-password answer for a principal over 256 bytes; a store error from the admin password check is returned as an error and logged at ERROR, and a bcrypt slot timeout is returned as an error and not logged as a database error; on the console, a store error during the password check answers 500 and records nothing, and a wrong password answers the form's error and records one failure.

Integration, tests/test_rate_limiting.py, which CI runs against SQLite, PostgreSQL, Cassandra and MongoDB. Each test sends from two random addresses in 127.0.0.0/8, so the file records nothing against 127.0.0.1 and can be run again at once; a test skips when the endpoint is not a loopback address or the address cannot be bound. Five failures for a user from one address refuse that user there and not from the other address; 20 failures over 20 names from one address refuse a 21st name there and not from the other; the admin password check on the management API, the console login form and /metrics each answer 429 to the right password after five failures from one address and accept it from the other. Against one SQLite server the file passed three times back to back (7 tests each, about 22 seconds a run), left 150 rows from 24 addresses and none from 127.0.0.1, and the admin then logged in from 127.0.0.1. Against one PostgreSQL server it passed twice back to back (7 tests each) and left 100 rows from 16 addresses and none from 127.0.0.1.

Live, SQLite, management API and console, A = 127.0.0.1 and B = 127.0.0.2: five wrong admin passwords from A, then 429 for the sixth and for the correct password from A; 200 (management) and 303 (console) for the correct password from B; 401 (management) or the form's error (console) for a wrong password as another principal from A; rows (admin, 127.0.0.1) 5.

Live, 80 concurrent wrong admin passwords: 429 for 75, 401 for 5, 5 rows, 429 for the next attempt. Refusals took 38 to 175 ms including the TLS handshake.

Negative controls

Each on the committed tree and reverted with git afterwards:

  • Source address replaced by a constant: the two-address unit test fails at "another address is admitted", and the live check answers 429 to the correct password from B.
  • In-flight count ignored: the burst unit test fails with 40 admitted against a cap of 5; the live burst runs 13 verifications and answers 500 to 67 (the 10-second slot wait refusing the queue).
  • Semaphore bound raised to 1000: the storage burst test fails with 32 running at once.
  • Permit held by the async caller instead of the blocking call: the two cancellation tests fail with 6 and 8 bcrypt calls running at once, and with 7 each once the tests accept an overload refusal.
  • Permit dropped before the blocking call, with the tests accepting an overload refusal: the burst test fails with 32 verifications running at once.
  • Limiter keyed on the full IPv6 address: the three /64 tests fail.
  • Reservation released before the failure's row is written: the held-write test fails.
  • Length check removed: the long-principal test fails, because the store is read.
  • Management API answering 429 when the attempts store cannot be read: the management refusal test fails.
  • Console recording a failure on a store error during the password check: the console store-error test fails with one row.
  • Console reading a store error as an unknown admin: both console admin-check tests fail.
  • Principal logged with {}: the log test fails and its output shows the forged line.
  • The database-error log line restored for a bcrypt slot timeout: both timeout tests fail.
  • One server build against tests/test_rate_limiting.py, with four changes at once: the admin's wrong password not recorded on the management API (the management API and /metrics tests fail), the SQLite per-user count taken over every address (the two-address test fails), the SQLite per-address count forced to 0 (the 20-name test fails), and the console's wrong password not recorded (the console test fails). The two older tests pass.

Commands run

  • cargo +1.97.0 fmt --all -- --check: clean.
  • cargo +1.97.0 clippy --locked --workspace --all-targets -- -D warnings: clean.
  • cargo test --locked -p extenddb-storage -p extenddb-storage-sqlite -p extenddb-server on Rust 1.99.0 and on 1.88.0: server 257, storage 145 and storage-sqlite 163 passed with 1 ignored, on each.
  • cargo +1.88.0 check --locked --workspace --all-targets: clean.
  • pytest against a SQLite debug build: tests/test_rate_limiting.py with the console, management, metrics, auth and cache test files, 9 files, 161 passed and 1 skipped.
  • pytest tests/test_rate_limiting.py against a PostgreSQL build, twice: 7 passed each.

These ran before the last two commits. The first changes the refusal log line's first words and the name of the test that checks it; after it, cargo +1.97.0 fmt --all -- --check, cargo +1.97.0 clippy --locked -p extenddb-server --all-targets -- -D warnings and the server crate's rate limit unit tests ran again. The second moves the management API test from GET /management/admins to GET /management/accounts (see #643), and CI runs it on all four backends.

Checklist

  • I have read CONTRIBUTING.md
  • All tests pass (cargo test --workspace)
  • Code is formatted (cargo fmt --check)
  • Clippy is clean (cargo clippy -- -W clippy::pedantic)
  • I have added or updated tests for new functionality
  • I have updated documentation if behavior changed
  • Breaking changes are noted below (if any)
  • If this changes the wire protocol, Storage trait, auth model, on-disk
    format, or public CLI surface, an RFC has been accepted or is linked
    below. Otherwise, an ADR captures the decision (link below).

CI ran the workspace tests on Rust 1.88.0 and on stable, and the integration suites on all four backends, and all of them passed. CodeQL reports 13 hard-coded cryptographic value alerts, each on a password literal inside a test module. Clippy ran with -D warnings, without the pedantic group. RateLimitStore::count_principal_failures gains a source address parameter, which all four backends implement. It has no RFC or ADR.

ADR / RFC: none

Breaking changes

There are no configuration or schema changes. These behaviours change:

  • A storage error while reading the attempt counts answers 500 (it answered 429 with the body Internal error).
  • A storage error while verifying the password, including a timed-out wait for a bcrypt slot, answers 500 and records nothing, on the console and on the management API. On main the console read the error as an unknown admin and went on to answer the login form's error and record a failure, and the management API answered 500 and recorded a failure.
  • The per-address limit (20 in 15 minutes) now applies to every management route and /metrics. On main it applied to the console login and to role assumption, the one management handler that passed an address.
  • Failures that main recorded on the management API and /metrics carry no source address and stop counting toward any limit after upgrade, a one-time loosening of at most fifteen minutes. Console and role-assumption rows for an IPv4 peer carry the address and keep counting. Measured on SQLite with three console rows and two management rows that main wrote for admin from 127.0.0.1: after upgrade, wrong management passwords for admin from 127.0.0.1 answered 401, 401, then 429. From the code, not measured: rows main stored for an IPv6 peer, and console rows it stored as ::ffff:a.b.c.d on a dual-stack listener, stop matching, because the limiter now keys those peers by /64 prefix or by IPv4 address.
  • A principal longer than 256 bytes is answered 401 on the management API and /metrics and with the login form's error on the console, and nothing is recorded for it.
  • The log line written when a principal is refused begins Login refused: (it began Account lockout:) and prints the principal with {:?}, so newlines and control characters in it are escaped.
  • The 429 messages are unchanged.

By submitting this pull request, I confirm that my contribution is made under
the terms of the Apache License 2.0 and I agree to the Developer Certificate of
Origin (DCO). See CONTRIBUTING.md for details.

…source

Every backend verified passwords with its own spawn_blocking call and no
bound on how many ran at once; only hashing in the server took a permit.
Move verification into extenddb_storage::password::verify_password, which
shares one process-wide semaphore of four slots with hashing, waits at most
ten seconds for a slot, and answers OpError::Internal when none frees so the
caller returns a server error instead of queueing without bound.

RateLimitStore::count_principal_failures now takes the source address and
counts only failures from that address, on all four backends and the test
double, so failures from one source do not lock the principal out of others.
…pts before the password check

The management API and the console refused a principal after five failed
logins counted over every address, and every management handler except
role assumption passed no source address, so five wrong passwords for a
known admin name from anywhere answered 429 to that admin for fifteen
minutes, from every address, on both surfaces. The limit check also ran
before the password check and the failure was recorded after it, so a
burst of concurrent wrong passwords ran one bcrypt verification each,
past the cap of five.

Limits now apply per principal and peer address (5 failures in 15 minutes)
and per peer address (20 failures in 15 minutes). Every management handler
passes the connection's peer address through a ConnectInfo extractor; the
console already had it. An attempt reserves its slot in a process-wide
registry before any password is checked and holds it until the failure is
recorded, so a burst is admitted up to the cap and the rest answer 429.
A storage error before or during the password check answers 500 and
records nothing against the caller.

Hashing in the server takes its permit from the bcrypt semaphore shared
with verification in the storage crate.
Add a Login Rate Limiting section to the security model manual with the
limits, the reservation before the password check, the bcrypt bound, the
source address rule and what a reverse proxy means for it. Point the
architecture guide at it, describe what RateLimitStore counts in the
storage design doc, and list the trait's real methods in the storage
extension manual.
verify_password took a slot from the bcrypt semaphore in the async caller,
and the slot was released when the caller's future was dropped. A login
request cancelled while bcrypt ran (a client that closed the connection)
freed its slot at once while the blocking call kept running, so more than
four bcrypt calls could run at the same time.

The permit now moves into the spawn_blocking closure and is dropped there
after bcrypt returns. Hashing moves into extenddb_storage::password beside
verification and takes the same path, and the server's hash_password calls
it. The new tests cancel four callers while bcrypt runs, start four more
verifications, and check that no more than four bcrypt calls ran at once.
The login limits were keyed on the peer's full address. An IPv6 client
can send from any address in the /64 assigned to it, so it could use a
new address for every attempt and never reach either limit.

begin_login now keys a non-mapped IPv6 peer by its /64 prefix
(2001:db8:1:2::/64) for the limiter. IPv4 peers and IPv4-mapped IPv6
peers keep the full IPv4 address, and aws:SourceIp still sees the full
address. The security model states the /64 rule.
rate_limit.rs holds 267 lines of code and 285 lines of tests. The tests
move unchanged to rate_limit/tests.rs, following leading_keys.rs, so the
tests added next do not take the file past 600 lines.
An admitted attempt keeps its reservation until its failure row is
written. The new test holds one row write open and checks that an
admission for the same principal and source made during the write is
refused.
A login attempt whose principal is longer than 256 bytes is refused
before the rate limiter reads the store or reserves an attempt. The
management API and /metrics answer 401 Invalid credentials, the console
shows its invalid credentials page, and nothing is recorded. Admin names
are at most 64 bytes and account_id/user_name at most 141, so no
principal that can log in is affected.

Each surface now builds its answer to a refused attempt in one function,
and unit tests check each answer: 429 for a limit, 500 when the attempts
store cannot be read, and the wrong password answer for a principal over
the length limit.
The console login handler now checks the credentials in one function and
settles the attempt in another, so both can be tested without a catalog.
Unit tests check that a store error from the admin password check is
returned as an error and not read as an unknown admin, that a store
error during the password check answers 500 and records no failure, and
that a wrong password answers the login form's error and records one.
The line logged when a principal is refused for a source address printed
the principal as the caller sent it, so a principal containing a newline
could start a forged log line. The principal is now printed with {:?},
which escapes newlines and other control characters. A principal that
reaches this line is at most 256 bytes long.
A password check that waits more than 10 seconds for a bcrypt slot is
refused, and the storage crate logs the refusal at WARN under its own
message. The management API and the console then logged the same
refusal again at ERROR as "DB error during auth". They now skip that
ERROR line for the overload and log every other storage error as before.
…n addresses

tests/test_rate_limiting.py sent every failed login from 127.0.0.1, and
the limit of 20 failures per source address now covers the management
API, so a third run within 15 minutes refused the admin on 127.0.0.1 and
failed in setup. Each test now sends from two random addresses in
127.0.0.0/8, so the file can be run again at once and records nothing
against 127.0.0.1. A test skips when the endpoint is not a loopback
address or the address cannot be bound.

New tests check, on each backend the suite runs against, that failures
for a user from one address do not refuse that user from another, that
an address is refused after 20 failures across users, and that the
admin password check on the management API, the console login form and
/metrics each record failures and refuse after five.
The security model now lists the callers that reach ExtendDB from one
address and so share one set of login limits: a proxy or load balancer
that does not preserve the client address, the server host itself
(extenddb manage and a /metrics scraper), and a NAT gateway. It says how
to recover an address that is refused, and advises putting a proxy or
load balancer in front of ExtendDB only when it preserves the client
address, or when callers behind it are trusted not to lock each other
out. The deployment checklist's proxy line says the same.

Docs and comments that described the limits as account lockout now say
login rate limiting per user and source address.
The burst and cancellation tests queue wrong-password checks behind
running bcrypt calls. When the host is slow enough that no slot frees
within the 10 second wait, a queued check is refused as overloaded, and
the tests failed on that refusal although no bcrypt call ran past the
bound. A queued check may now be refused as wrong or as overloaded; the
high-water mark checks are unchanged.
The warning written when a principal reaches its limit from one source
address began "Account lockout:". With the per-address limits the
principal is refused from that address only and no account is locked,
so the line now begins "Login refused:". The unit test that checks the
line escapes the principal is renamed to match.
Comment thread crates/server/src/management/auth.rs Dismissed
Comment thread crates/server/src/management/auth.rs Dismissed
Comment thread crates/storage/src/password.rs Dismissed
Comment thread crates/storage/src/password.rs Dismissed
Comment thread crates/storage/src/password.rs Dismissed
Comment thread crates/storage/src/password.rs Dismissed
Comment thread crates/storage/src/password.rs Dismissed
Comment thread crates/storage/src/password.rs Dismissed
test_admin_wrong_password_is_counted_on_the_management_api sent its
requests to GET /management/admins. On Cassandra that route answers 500
to every caller, because the bootstrapper writes the first admin without
created_at and list_admins cannot read the row. The test's last request,
the right password from the second address, therefore got 500 instead of
200 on Cassandra. The test now uses GET /management/accounts, which also
requires an admin and lists rows that carry created_at on every backend.
The Cassandra route is #643.
@navareamol navareamol added this to the 1.0 milestone Nov 3, 2026
@navareamol navareamol added the release-blocker Must ship for its milestone's release label Nov 3, 2026
@hitesh96db hitesh96db self-assigned this Nov 3, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working release-blocker Must ship for its milestone's release security v1 Needed for the 1.0 release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants