Hall of Fame image from https://openclipart.org/detail/120343/trophy
Back to Hall of Fame Contents Back to Wekan Website

Contents / LockoutBleed

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.
  • LockoutBleed — the bundled brute-force lockout never fired, so passwords could be guessed without limit (CWE-307 Improper Restriction of Excessive Authentication Attempts)
  • Chained with user enumeration: a login for a real user runs bcrypt (~50 ms) while a missing one returns in ~2 ms, a timing oracle that reveals which usernames and emails exist (CWE-208 Observable Timing Discrepancy)
  • Affected the DDP login method and the REST POST /users/login endpoint (which also named the missing user outright and had no lockout at all)
  • Fixed at upcoming WeKan release


Details

LockoutBleed — the shipped brute-force lockout never fired (CWE-307)

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 stringloginInfo.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.

User enumeration by bcrypt timing (CWE-208)

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).

The REST twin was worse

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.