| CVE | Vulnerability name | Date | Responsible Security Disclosure by | Vulnerabilities |
|---|---|---|---|---|
|
GHSA-2g94-9x3m-hv37
|
LockoutBleed |
2026-08-04 |
NinjaGPT and xet7![]() Coordinated disclosure via GitHub Security Advisory GHSA-2g94-9x3m-hv37. |
|
WeKan bundles wekan-accounts-lockout to stop password guessing: by default,
three failed logins lock the account for sixty seconds. It had been doing nothing at all.
Both of its Accounts.validateLoginAttempt hooks decided whether an attempt
was a failure by comparing the error's reason string —
loginInfo.error.reason !== 'Incorrect password' for a known user,
!== 'User not found' for an unknown one — and returned early otherwise.
That reason never arrives. Meteor's accounts-base ships
ambiguousErrorMessages defaulting to true, so its
_handleError rewrites every credential failure — wrong password, no
such user, no password set — to one generic sentence before any
validateLoginAttempt hook runs:
// meteor/accounts-base — accounts_server.js
_handleError = (msg, throwError = true, errorCode = 403) => {
const isErrorAmbiguous = this._options.ambiguousErrorMessages ?? true;
const error = new Meteor.Error(
errorCode,
isErrorAmbiguous
? 'Something went wrong. Please check your credentials.' // <-- the reason the hooks see
: msg, // never 'Incorrect password'
);
...
};
So the literals never matched, both hooks returned early, the failure counter was never incremented, and no account ever locked. The report confirmed it dynamically: the lockout collection stayed empty and unlimited failed logins were accepted — no CAPTCHA, no rate limit, no lockout.
The fix decides from the attempt's structural fields instead of a localized,
Meteor-internal string WeKan does not control (new
packages/wekan-accounts-lockout/src/loginFailureDecision.js): a password
login of a known user that carries an error is a countable failure; a password login with
no matched user that carries an error is a countable unknown-user failure. The one error
deliberately not counted is no-2fa-code — accounts-2fa throws it
after the password already checked out, to ask for the second factor, so it is
the normal first leg of every two-factor sign-in and counting it would lock out
legitimate 2FA users. A wrong second factor (invalid-2fa-code) still
counts, because there the password was already correct.
The accounts-password login path runs a bcrypt comparison (~50 ms) only when the user exists and has a local password. For a missing user — or an LDAP/OIDC-only user with no local password — it throws immediately (~2 ms) with no bcrypt work. The two response-time distributions do not overlap, so an unauthenticated attacker can tell whether any given username or email exists with near-100% reliability, no matter how uniform the error text is. With enumeration free and guessing unthrottled, any account with a weak, guessable or breached password — including an administrator's — could be taken over, a successful login returning a full resume login token.
The standard mitigation is applied: whenever the real path would skip bcrypt, one
dummy bcrypt comparison against a fixed cost-10 hash is performed so the
missing-user path costs about the same as a real check (new
server/lib/loginTimingDefense.js). On the DDP login method a
timing-normalization login handler runs ahead of the built-in password handler, looks the
user up, and — when there is no local password to check — burns the
compensating time before falling through; it never authenticates
(server/loginTimingNormalization.js).
POST /users/login in server/apiAuthRoutes.js checks the password
directly with Accounts._checkPasswordAsync and never runs the DDP lockout
hooks, so on its own it had no brute-force protection at all — and it enumerated by
message, throwing a distinct “User with that username or email address
not found.” for a missing user while a wrong password threw the ambiguous one.
It now fails missing-user and wrong-password with the same uniform error, runs the
same dummy-bcrypt timing equaliser for a missing or password-less account, and throttles
failed attempts per client address (new server/lib/loginAttemptThrottle.js,
default 10 failures / 60 s → 60 s lockout). Only failures count and a success clears
the client's counter, so correct-credential clients are never impeded;
X-Forwarded-For is honoured only when HTTP_FORWARDED_COUNT
declares the proxy depth, so the header cannot be spoofed to dodge the throttle.
Four plain-Node unit suites pin each half: tests/loginFailureDecision.test.cjs
(the ambiguous reason is counted, success still enforces an active lock,
no-2fa-code never locks anyone), tests/loginTimingDefense.test.cjs,
tests/loginAttemptThrottle.test.cjs (the time-injected throttle and the key
resolver), and tests/loginBruteForceEnumerationWiring.test.cjs (the fragile
reason-string guards stay gone and the REST endpoint keeps its uniform error, timing
equaliser and throttle).
Reported by NinjaGPT via GitHub Security
Advisory
GHSA-2g94-9x3m-hv37,
reproduced against v8.35. Fixed at the
upcoming WeKan release.