The load tests (#133) sent forty clients' wrong passwords to /api/login, 54 attempts a second, which takes no account. Everyone else's requests took 4s (median 3.96s for /api/feeds, about 20ms otherwise) and `ipx status`, the healthcheck, 1.3s (#137): each attempt was an Argon2id check, tens of milliseconds of CPU, run inside the handler on one of the runtime's workers, so a handful at once held every worker the rest of the server answers on. And a wrong password for an account's name was refused a median 31ms later than one for a made-up name (#138), since only a name with a hash was checked: the answer read the same, the time said which names are accounts. auth::check_password runs the check on the blocking pool, at most half the cores at once, so a flood waits on itself, and checks a name with no account, or no password, against a fixed decoy hash, false in the same time. Under the same flood the rest of the site answers at p95 57ms, `ipx status` at most 90ms, the gap is 0.2ms, and twice as many attempts are answered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -315,11 +315,8 @@ async fn login(
|
||||
) -> Result<Response, ApiError> {
|
||||
let name = body.name.trim().to_ascii_lowercase();
|
||||
let user = state.ctx.db.user_by_name(&name).await?;
|
||||
// The same answer either way: whether a name exists is not something to leak.
|
||||
let ok = user
|
||||
.as_ref()
|
||||
.and_then(|u| u.pass_hash.as_deref())
|
||||
.is_some_and(|h| crate::auth::verify_password(&body.password, h));
|
||||
// The same answer either way, in the same time: whether a name exists is not something to leak.
|
||||
let ok = crate::auth::check_password(body.password, user.as_ref().and_then(|u| u.pass_hash.clone())).await;
|
||||
if !ok {
|
||||
tracing::warn!(user = %name, "failed sign-in");
|
||||
return Ok((StatusCode::UNAUTHORIZED, "wrong name or password").into_response());
|
||||
|
||||
Reference in New Issue
Block a user