{"id":"GHSA-cr7p-cr3q-h5cm","title":" Budibase: Account Enumeration via Login Lockout Response Differential","summary":" Budibase: Account Enumeration via Login Lockout Response Differential","severity":"medium","cvss":5.3,"cwe":["CWE-204"],"vendor":"budibase","product":"@budibase/server","ecosystem":"npm","affected":["@budibase/server <= 3.38.1"],"published":"2026-07-24","updated":"2026-07-24","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-cr7p-cr3q-h5cm","references":[{"url":"https://github.com/Budibase/budibase/security/advisories/GHSA-cr7p-cr3q-h5cm"},{"url":"https://github.com/Budibase/budibase/pull/19108"},{"url":"https://github.com/Budibase/budibase/commit/eaae816ab81615c07eb10e4619af078d00e2a706"},{"url":"https://github.com/Budibase/budibase/releases/tag/3.39.25"},{"url":"https://github.com/advisories/GHSA-cr7p-cr3q-h5cm"}],"tags":["ghsa","npm"],"ingestedAt":"2026-07-24T22:40:27.457Z","slug":"GHSA-cr7p-cr3q-h5cm","body":"## Overview\n\n## Summary\n\nThe 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.\n\n## Details\n\nThe vulnerability exists in two files that implement the login lockout feature:\n\n**`packages/worker/src/middleware/lockout.ts:18-36`** — The lockout middleware only blocks requests for users that exist in the database AND are locked:\n\n```typescript\nexport default async (ctx: Ctx, next: Next) => {\n  const email = ctx.request.body.username\n  if (!email) {\n    return await next()\n  }\n  const dbUser = await userSdk.db.getUserByEmail(email)\n  if (dbUser && (await isLocked(email))) {  // line 26: non-existing users skip this entirely\n    ctx.set(\"X-Account-Locked\", \"1\")\n    ctx.set(\"Retry-After\", String(env.LOGIN_LOCKOUT_SECONDS))\n    ctx.throw(403, \"Account temporarily locked. Try again later.\")\n  }\n  return await next()\n}\n```\n\n**`packages/worker/src/api/controllers/global/auth.ts:127-141`** — The login handler only increments the failure counter for existing users:\n\n```typescript\nif (err || !user) {\n  if (dbUser) {          // line 129: non-existing users never trigger onFailed()\n    await onFailed(email)\n  }\n  if (await isLocked(email)) {\n    return handleLockoutResponse(ctx, email)\n  }\n  // ...\n  return passportCallback(ctx, user as any, err, info)\n}\n```\n\n**Execution flow for existing users (after 5 failed attempts):**\n1. `lockout` middleware → `getUserByEmail` returns user → `isLocked` returns true → 403 + `X-Account-Locked: 1` + `Retry-After: 900` + \"Account temporarily locked\"\n\n**Execution flow for non-existing users (any number of attempts):**\n1. `lockout` middleware → `getUserByEmail` returns null → `dbUser && isLocked` is false → passes through\n2. Login handler → passport fails → `if (dbUser)` is false → `onFailed()` never called → lock never set\n3. Always returns 403 \"Unauthorized\"\n\nNo 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.\n\n## PoC\n\n```bash\n# Test against a known-existing email and a non-existing email\n# Replace 'default' with the target tenant ID\n\n# Step 1: Send 6 login attempts for an existing user\necho \"=== Testing existing user ===\"\nfor i in $(seq 1 6); do\n  echo \"--- Attempt $i ---\"\n  curl -s -D - -X POST http://localhost:10000/api/global/auth/default/login \\\n    -H 'Content-Type: application/json' \\\n    -d '{\"username\":\"local@budibase.com\",\"password\":\"wrongpassword\"}' 2>&1 \\\n    | grep -E 'HTTP/|X-Account-Locked|Retry-After|locked|Unauthorized'\n  echo \"\"\ndone\n\n# Expected: Attempts 1-5 return \"Unauthorized\"\n# Attempt 6 returns: \"Account temporarily locked\" + X-Account-Locked: 1 + Retry-After: 900\n\n# Step 2: Send 6 login attempts for a non-existing user\necho \"=== Testing non-existing user ===\"\nfor i in $(seq 1 6); do\n  echo \"--- Attempt $i ---\"\n  curl -s -D - -X POST http://localhost:10000/api/global/auth/default/login \\\n    -H 'Content-Type: application/json' \\\n    -d '{\"username\":\"nonexistent@example.com\",\"password\":\"wrongpassword\"}' 2>&1 \\\n    | grep -E 'HTTP/|X-Account-Locked|Retry-After|locked|Unauthorized'\n  echo \"\"\ndone\n\n# Expected: All 6 attempts return \"Unauthorized\" — no lockout ever triggers\n\n# The difference in behavior after 5 attempts confirms whether the email exists.\n```\n\n## Impact\n\n- **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.\n- **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).\n- **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.\n- **Enables further attacks**: Confirmed valid emails can be used for targeted phishing, credential stuffing against other services, or social engineering.\n\n## Recommended Fix\n\nThe 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:\n\n**`packages/worker/src/middleware/lockout.ts`** — Remove the `dbUser` check:\n\n```typescript\nexport default async (ctx: Ctx, next: Next) => {\n  const email = ctx.request.body.username\n  if (!email) {\n    return await next()\n  }\n  // Check lock status based on email alone, not user existence\n  if (await isLocked(email)) {\n    ctx.set(\"X-Account-Locked\", \"1\")\n    ctx.set(\"Retry-After\", String(env.LOGIN_LOCKOUT_SECONDS))\n    ctx.throw(403, \"Account temporarily locked. Try again later.\")\n  }\n  return await next()\n}\n```\n\n**`packages/worker/src/api/controllers/global/auth.ts`** — Remove the `dbUser` guard around `onFailed`:\n\n```typescript\nif (err || !user) {\n  // Always increment failure counter regardless of user existence\n  await onFailed(email)\n  if (await isLocked(email)) {\n    return handleLockoutResponse(ctx, email)\n  }\n  // ...\n}\n```\n\nAdditionally, consider adding IP-based rate limiting to the login endpoint (similar to what already exists on the password reset endpoint) to limit enumeration throughput.\n\n## Affected packages\n\n- `@budibase/server <= 3.38.1`\n\n## Remediation\n\nRefer to the advisory for the patched release.","depth":"sunlit","depthScore":29,"depthScoreParts":{"impact":29.2,"likelihood":0,"exploitation":0,"ransomware":0},"changes":[]}