@budibase/worker 3.23.24 → 3.23.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@budibase/worker",
3
3
  "email": "hi@budibase.com",
4
- "version": "3.23.24",
4
+ "version": "3.23.25",
5
5
  "description": "Budibase background service",
6
6
  "main": "src/index.ts",
7
7
  "repository": {
@@ -109,5 +109,5 @@
109
109
  }
110
110
  }
111
111
  },
112
- "gitHead": "083c01f79a93ea8819c45370178ded6909dd2ef4"
112
+ "gitHead": "aaca0ff0f2cf886148a72826d4ecd9c8b90a62b2"
113
113
  }
@@ -5,6 +5,7 @@ import {
5
5
  events,
6
6
  utils as utilsCore,
7
7
  configs,
8
+ cache,
8
9
  } from "@budibase/backend-core"
9
10
  import {
10
11
  ConfigType,
@@ -37,6 +38,44 @@ const { setCookie, getCookie, clearCookie } = utilsCore
37
38
 
38
39
  // LOGIN / LOGOUT
39
40
 
41
+ const normalizeEmail = (e: string) => (e || "").toLowerCase()
42
+ const failKey = (email: string) => `auth:login:fail:${normalizeEmail(email)}`
43
+ const lockKey = (email: string) => `auth:login:lock:${normalizeEmail(email)}`
44
+ const isLocked = async (email: string) => {
45
+ return !!(await cache.get(lockKey(email)))
46
+ }
47
+
48
+ const handleLockoutResponse = (ctx: Ctx, email: string) => {
49
+ ctx.set("X-Account-Locked", "1")
50
+ ctx.set("Retry-After", String(env.LOGIN_LOCKOUT_SECONDS))
51
+ console.log(
52
+ `[auth] login blocked (post-failure) due to lock email=${normalizeEmail(email)}`
53
+ )
54
+ return ctx.throw(403, "Account temporarily locked. Try again later.")
55
+ }
56
+ const onFailed = async (email: string) => {
57
+ if (!email) return
58
+ const key = failKey(email)
59
+ const currentAttempt = Number((await cache.get(key)) || 0) || 0
60
+ const nextAttempt = currentAttempt + 1
61
+ await cache.store(key, nextAttempt, env.LOGIN_LOCKOUT_SECONDS)
62
+ console.log(
63
+ `[auth] failed login email=${normalizeEmail(email)} count=${nextAttempt}`
64
+ )
65
+ if (nextAttempt >= env.LOGIN_MAX_FAILED_ATTEMPTS) {
66
+ await cache.store(lockKey(email), "1", env.LOGIN_LOCKOUT_SECONDS)
67
+ await cache.destroy(key)
68
+ console.log(
69
+ `[auth] account locked email=${normalizeEmail(email)} for ${env.LOGIN_LOCKOUT_SECONDS}s`
70
+ )
71
+ }
72
+ }
73
+ const clearFailureState = async (email: string) => {
74
+ if (!email) return
75
+ await cache.destroy(failKey(email))
76
+ await cache.destroy(lockKey(email))
77
+ }
78
+
40
79
  async function passportCallback(
41
80
  ctx: Ctx,
42
81
  user: User,
@@ -75,14 +114,37 @@ export const login = async (
75
114
  ) => {
76
115
  const email = ctx.request.body.username
77
116
 
78
- const user = await userSdk.db.getUserByEmail(email)
79
- if (user && (await userSdk.db.isPreventPasswordActions(user))) {
117
+ const dbUser = await userSdk.db.getUserByEmail(email)
118
+ if (dbUser && (await userSdk.db.isPreventPasswordActions(dbUser))) {
119
+ console.log(
120
+ `[auth] login prevented due to sso enforcement email=${normalizeEmail(email)}`
121
+ )
80
122
  ctx.throw(403, "Invalid credentials")
81
123
  }
82
124
 
83
125
  return passport.authenticate(
84
126
  "local",
85
127
  async (err: any, user: User, info: any) => {
128
+ if (err || !user) {
129
+ if (dbUser) {
130
+ await onFailed(email)
131
+ }
132
+ if (await isLocked(email)) {
133
+ return handleLockoutResponse(ctx, email)
134
+ }
135
+ const reason =
136
+ (info && info.message) || (err && err.message) || "unknown"
137
+ console.log(
138
+ `[auth] password auth failed email=${normalizeEmail(email)} reason=${reason}`
139
+ )
140
+ // delegate to shared passport failure handling to preserve specific messages (e.g. expired)
141
+ return passportCallback(ctx, user as any, err, info)
142
+ }
143
+
144
+ await clearFailureState(email)
145
+ console.log(
146
+ `[auth] password auth success email=${normalizeEmail(user.email)}`
147
+ )
86
148
  await passportCallback(ctx, user, err, info)
87
149
  await context.identity.doInUserContext(user, ctx, async () => {
88
150
  await events.auth.login("local", user.email)
@@ -133,6 +195,60 @@ export const reset = async (
133
195
  ) => {
134
196
  const { email } = ctx.request.body
135
197
 
198
+ const lcEmail = (email || "").toLowerCase()
199
+ const ip = (ctx.ip || "").toString()
200
+
201
+ // rate limit keys
202
+ const emailKey = `auth:pwdreset:email:${lcEmail}`
203
+ const ipKey = `auth:pwdreset:ip:${ip}`
204
+
205
+ const increment = async (key: string, windowSeconds: number) => {
206
+ const currentAttempt = Number((await cache.get(key)) || 0) || 0
207
+ const nextAttempt = currentAttempt + 1
208
+ await cache.store(key, nextAttempt, windowSeconds)
209
+ return nextAttempt
210
+ }
211
+
212
+ // apply per-email and per-ip rate limits
213
+ const nextEmail = await increment(
214
+ emailKey,
215
+ env.PASSWORD_RESET_RATE_EMAIL_WINDOW_SECONDS
216
+ )
217
+ const nextIp = await increment(
218
+ ipKey,
219
+ env.PASSWORD_RESET_RATE_IP_WINDOW_SECONDS
220
+ )
221
+
222
+ const emailLimited = nextEmail > env.PASSWORD_RESET_RATE_EMAIL_LIMIT
223
+ const ipLimited = nextIp > env.PASSWORD_RESET_RATE_IP_LIMIT
224
+
225
+ if (emailLimited || ipLimited) {
226
+ // surfaced for ui to display
227
+ ctx.set(
228
+ "X-RateLimit-Email-Limit",
229
+ String(env.PASSWORD_RESET_RATE_EMAIL_LIMIT)
230
+ )
231
+ ctx.set(
232
+ "X-RateLimit-Email-Remaining",
233
+ String(Math.max(env.PASSWORD_RESET_RATE_EMAIL_LIMIT - nextEmail, 0))
234
+ )
235
+ ctx.set("X-RateLimit-IP-Limit", String(env.PASSWORD_RESET_RATE_IP_LIMIT))
236
+ ctx.set(
237
+ "X-RateLimit-IP-Remaining",
238
+ String(Math.max(env.PASSWORD_RESET_RATE_IP_LIMIT - nextIp, 0))
239
+ )
240
+ // best-effort retry window
241
+ const retryAfter = Math.max(
242
+ env.PASSWORD_RESET_RATE_EMAIL_WINDOW_SECONDS,
243
+ env.PASSWORD_RESET_RATE_IP_WINDOW_SECONDS
244
+ )
245
+ ctx.set("Retry-After", String(retryAfter))
246
+ console.log(
247
+ `[auth] password reset rate limited email=${lcEmail} ip=${ip} emailCount=${nextEmail} ipCount=${nextIp}`
248
+ )
249
+ return ctx.throw(429, "Too many password reset requests. Try again later.")
250
+ }
251
+
136
252
  await authSdk.reset(email)
137
253
 
138
254
  ctx.body = {
@@ -10,6 +10,7 @@ import {
10
10
  tenancy,
11
11
  } from "@budibase/backend-core"
12
12
  import * as pro from "@budibase/pro"
13
+ import { BUILDER_URLS } from "@budibase/shared-core"
13
14
  import {
14
15
  AIInnerConfig,
15
16
  Config,
@@ -676,23 +677,23 @@ export async function configChecklist(ctx: Ctx<void, ConfigChecklistResponse>) {
676
677
  apps: {
677
678
  checked: workspaces.length > 0,
678
679
  label: "Create your first app",
679
- link: "/builder/portal/workspaces",
680
+ link: BUILDER_URLS.WORKSPACES,
680
681
  },
681
682
  smtp: {
682
683
  checked: !!smtpConfig,
683
684
  label: "Set up email",
684
- link: "/builder/portal/settings/email",
685
+ link: BUILDER_URLS.SETTINGS_EMAIL,
685
686
  fallback: smtpConfig?.fallback || false,
686
687
  },
687
688
  adminUser: {
688
689
  checked: userExists,
689
690
  label: "Create your first user",
690
- link: "/builder/portal/users/users",
691
+ link: BUILDER_URLS.SETTINGS_PEOPLE_USERS,
691
692
  },
692
693
  sso: {
693
694
  checked: !!googleConfig || !!oidcConfig,
694
695
  label: "Set up single sign-on",
695
- link: "/builder/portal/settings/auth",
696
+ link: BUILDER_URLS.SETTINGS_AUTH,
696
697
  },
697
698
  branding,
698
699
  }
@@ -2,6 +2,7 @@ import * as authController from "../../controllers/global/auth"
2
2
  import { auth } from "@budibase/backend-core"
3
3
  import Joi from "joi"
4
4
  import { loggedInRoutes } from "../endpointGroups"
5
+ import { lockout } from "../../../middleware"
5
6
 
6
7
  function buildAuthValidation() {
7
8
  // prettier-ignore
@@ -31,6 +32,7 @@ loggedInRoutes
31
32
  .post(
32
33
  "/api/global/auth/:tenantId/login",
33
34
  buildAuthValidation(),
35
+ lockout,
34
36
  authController.login
35
37
  )
36
38
  .post("/api/global/auth/logout", authController.logout)
@@ -140,6 +140,59 @@ describe("/api/global/auth", () => {
140
140
  })
141
141
  })
142
142
  })
143
+
144
+ describe("lockout", () => {
145
+ it("locks the account after 5 failed attempts and unlocks after TTL", async () => {
146
+ const tenantId = config.tenantId!
147
+ const email = config.user!.email!
148
+ const correctPassword = config.userPassword
149
+ const wrongPassword = "incorrect123"
150
+ const { withEnv } = require("../../../../environment")
151
+
152
+ await withEnv(
153
+ {
154
+ LOGIN_MAX_FAILED_ATTEMPTS: 5,
155
+ LOGIN_LOCKOUT_SECONDS: 2,
156
+ },
157
+ async () => {
158
+ // 5 consecutive wrong attempts
159
+ for (let i = 0; i < 5; i++) {
160
+ await config.api.auth.login(tenantId, email, wrongPassword, {
161
+ status: 403,
162
+ })
163
+ }
164
+
165
+ // lock should be active now - further attempts (wrong or right) return 403
166
+ await config.api.auth.login(tenantId, email, wrongPassword, {
167
+ status: 403,
168
+ })
169
+
170
+ await config.api.auth.login(tenantId, email, correctPassword, {
171
+ status: 403,
172
+ })
173
+
174
+ // wait for TTL to expire (add buffer for redis expiration granularity)
175
+ await new Promise(r => setTimeout(r, 3000))
176
+
177
+ // Clear any remaining lockout state to ensure clean test
178
+ await config.doInTenant(async () => {
179
+ const { cache } = require("@budibase/backend-core")
180
+ const normalizeEmail = (e: string) => (e || "").toLowerCase()
181
+ const lockKey = (email: string) =>
182
+ `auth:login:lock:${normalizeEmail(email)}`
183
+ await cache.destroy(lockKey(email))
184
+ })
185
+
186
+ const response = await config.api.auth.login(
187
+ tenantId,
188
+ email,
189
+ correctPassword
190
+ )
191
+ expectSetAuthCookie(response)
192
+ }
193
+ )
194
+ })
195
+ })
143
196
  })
144
197
 
145
198
  describe("POST /api/global/auth/logout", () => {
@@ -87,6 +87,20 @@ const environment = {
87
87
  PASSPORT_OIDCAUTH_FAILURE_REDIRECT:
88
88
  process.env.PASSPORT_OIDCAUTH_FAILURE_REDIRECT || "/error",
89
89
 
90
+ LOGIN_MAX_FAILED_ATTEMPTS:
91
+ parseIntSafe(process.env.LOGIN_MAX_FAILED_ATTEMPTS) || 5,
92
+ LOGIN_LOCKOUT_SECONDS: parseIntSafe(process.env.LOGIN_LOCKOUT_SECONDS) || 900,
93
+
94
+ // password reset rate limiting
95
+ PASSWORD_RESET_RATE_EMAIL_LIMIT:
96
+ parseIntSafe(process.env.PASSWORD_RESET_RATE_EMAIL_LIMIT) || 3,
97
+ PASSWORD_RESET_RATE_EMAIL_WINDOW_SECONDS:
98
+ parseIntSafe(process.env.PASSWORD_RESET_RATE_EMAIL_WINDOW_SECONDS) || 900,
99
+ PASSWORD_RESET_RATE_IP_LIMIT:
100
+ parseIntSafe(process.env.PASSWORD_RESET_RATE_IP_LIMIT) || 20,
101
+ PASSWORD_RESET_RATE_IP_WINDOW_SECONDS:
102
+ parseIntSafe(process.env.PASSWORD_RESET_RATE_IP_WINDOW_SECONDS) || 900,
103
+
90
104
  // Budibase AI
91
105
  BUDIBASE_AI_API_KEY: process.env.BUDIBASE_AI_API_KEY,
92
106
  BUDIBASE_AI_DEFAULT_MODEL: process.env.BUDIBASE_AI_DEFAULT_MODEL,
@@ -0,0 +1,3 @@
1
+ export { default as cloudRestricted } from "./cloudRestricted"
2
+ export { handleScimBody } from "./handleScimBody"
3
+ export { default as lockout } from "./lockout"
@@ -0,0 +1,36 @@
1
+ import { cache } from "@budibase/backend-core"
2
+ import { Ctx } from "@budibase/types"
3
+ import { Next } from "koa"
4
+ import env from "../environment"
5
+ import * as userSdk from "../sdk/users"
6
+
7
+ const normalizeEmail = (e: string) => (e || "").toLowerCase()
8
+ const lockKey = (email: string) => `auth:login:lock:${normalizeEmail(email)}`
9
+
10
+ const isLocked = async (email: string) => {
11
+ return !!(await cache.get(lockKey(email)))
12
+ }
13
+
14
+ /**
15
+ * Middleware to check if an account is locked due to failed login attempts.
16
+ * If locked, returns 403 with appropriate headers.
17
+ */
18
+ export default async (ctx: Ctx, next: Next) => {
19
+ const email = ctx.request.body.username
20
+
21
+ if (!email) {
22
+ return await next()
23
+ }
24
+
25
+ const dbUser = await userSdk.db.getUserByEmail(email)
26
+ if (dbUser && (await isLocked(email))) {
27
+ console.log(
28
+ `[auth] login blocked due to lock email=${normalizeEmail(email)}`
29
+ )
30
+ ctx.set("X-Account-Locked", "1")
31
+ ctx.set("Retry-After", String(env.LOGIN_LOCKOUT_SECONDS))
32
+ ctx.throw(403, "Account temporarily locked. Try again later.")
33
+ }
34
+
35
+ return await next()
36
+ }
@@ -0,0 +1,95 @@
1
+ import lockout from "../lockout"
2
+ import { cache } from "@budibase/backend-core"
3
+ import * as userSdk from "../../sdk/users"
4
+ import env from "../../environment"
5
+
6
+ jest.mock("@budibase/backend-core", () => ({
7
+ cache: {
8
+ get: jest.fn(),
9
+ },
10
+ }))
11
+
12
+ jest.mock("../../sdk/users", () => ({
13
+ db: {
14
+ getUserByEmail: jest.fn(),
15
+ },
16
+ }))
17
+
18
+ describe("lockout middleware", () => {
19
+ let ctx: any
20
+ let next: jest.Mock
21
+
22
+ beforeEach(() => {
23
+ ctx = {
24
+ request: {
25
+ body: {},
26
+ },
27
+ set: jest.fn(),
28
+ throw: jest.fn(),
29
+ }
30
+ next = jest.fn()
31
+ jest.clearAllMocks()
32
+ })
33
+
34
+ it("should call next if no email provided", async () => {
35
+ ctx.request.body = {}
36
+
37
+ await lockout(ctx, next)
38
+
39
+ expect(next).toHaveBeenCalled()
40
+ expect(ctx.throw).not.toHaveBeenCalled()
41
+ })
42
+
43
+ it("should call next if user not found", async () => {
44
+ ctx.request.body = { username: "test@example.com" }
45
+ ;(userSdk.db.getUserByEmail as jest.Mock).mockResolvedValue(null)
46
+
47
+ await lockout(ctx, next)
48
+
49
+ expect(next).toHaveBeenCalled()
50
+ expect(ctx.throw).not.toHaveBeenCalled()
51
+ })
52
+
53
+ it("should call next if user exists but not locked", async () => {
54
+ ctx.request.body = { username: "test@example.com" }
55
+ ;(userSdk.db.getUserByEmail as jest.Mock).mockResolvedValue({
56
+ email: "test@example.com",
57
+ })
58
+ ;(cache.get as jest.Mock).mockResolvedValue(null)
59
+
60
+ await lockout(ctx, next)
61
+
62
+ expect(next).toHaveBeenCalled()
63
+ expect(ctx.throw).not.toHaveBeenCalled()
64
+ })
65
+
66
+ it("should throw 403 if user is locked", async () => {
67
+ ctx.request.body = { username: "test@example.com" }
68
+ ;(userSdk.db.getUserByEmail as jest.Mock).mockResolvedValue({
69
+ email: "test@example.com",
70
+ })
71
+ ;(cache.get as jest.Mock).mockResolvedValue("1")
72
+
73
+ // Mock ctx.throw to actually throw
74
+ ctx.throw = jest.fn().mockImplementation((status, message) => {
75
+ const error = new Error(message)
76
+ ;(error as any).status = status
77
+ throw error
78
+ })
79
+
80
+ await expect(lockout(ctx, next)).rejects.toThrow(
81
+ "Account temporarily locked. Try again later."
82
+ )
83
+
84
+ expect(next).not.toHaveBeenCalled()
85
+ expect(ctx.set).toHaveBeenCalledWith("X-Account-Locked", "1")
86
+ expect(ctx.set).toHaveBeenCalledWith(
87
+ "Retry-After",
88
+ String(env.LOGIN_LOCKOUT_SECONDS)
89
+ )
90
+ expect(ctx.throw).toHaveBeenCalledWith(
91
+ 403,
92
+ "Account temporarily locked. Try again later."
93
+ )
94
+ })
95
+ })