Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions packages/backend-core/src/cache/base/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,20 @@ export default class BaseCache {
}
}

async increment(
key: string,
ttlSeconds?: number,
opts = { useTenancy: true }
): Promise<number> {
key = opts.useTenancy ? generateTenantKey(key) : key
const client = await this.getClient()
const count = await client.increment(key)
if (count === 1 && ttlSeconds) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
await client.setExpiry(key, ttlSeconds)
}
return count
}

/**
* Delete the entry if the provided value matches the stored one.
*/
Expand Down
2 changes: 2 additions & 0 deletions packages/backend-core/src/cache/generic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,5 @@ export const withCacheWithDynamicTTL = <T>(
) => GENERIC.withCacheWithDynamicTTL(...args)
export const bustCache = (...args: Parameters<typeof GENERIC.bustCache>) =>
GENERIC.bustCache(...args)
export const increment = (...args: Parameters<typeof GENERIC.increment>) =>
GENERIC.increment(...args)
5 changes: 3 additions & 2 deletions packages/worker/src/api/routes/global/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as authController from "../../controllers/global/auth"
import { auth } from "@budibase/backend-core"
import Joi from "joi"
import { loggedInRoutes } from "../endpointGroups"
import { lockout } from "../../../middleware"
import { emailLockout, ipLockout } from "../../../middleware"

function buildAuthValidation() {
// prettier-ignore
Expand Down Expand Up @@ -32,7 +32,8 @@ loggedInRoutes
.post(
"/api/global/auth/:tenantId/login",
buildAuthValidation(),
lockout,
ipLockout,
emailLockout,
authController.login
)
.post("/api/global/auth/logout", authController.logout)
Expand Down
37 changes: 37 additions & 0 deletions packages/worker/src/api/routes/global/tests/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,43 @@ describe("/api/global/auth", () => {
)
})
})

describe("IP lockout", () => {
const flushIpCounters = async () => {
await config.doInTenant(async () => {
const { cache } = require("@budibase/backend-core")
for (const ip of ["127.0.0.1", "::1", "::ffff:127.0.0.1"]) {
await cache.destroy(`auth:login:ip:${ip}`)
}
})
}

beforeEach(flushIpCounters)
afterEach(flushIpCounters)

it("returns 429 with Retry-After once the IP limit is exceeded", async () => {
const { withEnv } = require("../../../../environment")
const tenantId = config.tenantId!
const email = config.user!.email!
const password = config.userPassword

await withEnv(
{ LOGIN_IP_LOCKOUT_LIMIT: 2, LOGIN_LOCKOUT_SECONDS: 900 },
async () => {
await config.api.auth.login(tenantId, email, password)
await config.api.auth.login(tenantId, email, password)

const res = await config.api.auth.login(
tenantId,
email,
password,
{ status: 429 }
)
expect(res.headers["retry-after"]).toBe("900")
}
)
})
})
})

describe("POST /api/global/auth/logout", () => {
Expand Down
2 changes: 2 additions & 0 deletions packages/worker/src/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ const environment = {
LOGIN_MAX_FAILED_ATTEMPTS:
parseIntSafe(process.env.LOGIN_MAX_FAILED_ATTEMPTS) || 5,
LOGIN_LOCKOUT_SECONDS: parseIntSafe(process.env.LOGIN_LOCKOUT_SECONDS) || 900,
LOGIN_IP_LOCKOUT_LIMIT:
Comment thread
jvcalderon marked this conversation as resolved.
parseIntSafe(process.env.LOGIN_IP_LOCKOUT_LIMIT) || 10,

// password reset rate limiting
PASSWORD_RESET_RATE_EMAIL_LIMIT:
Expand Down
3 changes: 2 additions & 1 deletion packages/worker/src/middleware/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { default as cloudRestricted } from "./cloudRestricted"
export { handleScimBody } from "./handleScimBody"
export { default as lockout } from "./lockout"
export { default as emailLockout } from "./emailLockout"
export { default as ipLockout } from "./ipLockout"
32 changes: 32 additions & 0 deletions packages/worker/src/middleware/ipLockout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { cache } from "@budibase/backend-core"
import { Ctx } from "@budibase/types"
import { Next } from "koa"
import env from "../environment"

const ipKey = (ip: string) => `auth:login:ip:${ip}`

/**
* Middleware to rate-limit login attempts by source IP.
* Blocks with 429 once the request count exceeds LOGIN_IP_LOCKOUT_LIMIT
* within the LOGIN_LOCKOUT_SECONDS window.
*/
export default async (ctx: Ctx, next: Next) => {
const ip = (ctx.ip || "").toString()

if (!ip) {
return await next()
}

const key = ipKey(ip)
const count = await cache.increment(key, env.LOGIN_LOCKOUT_SECONDS)

if (count > env.LOGIN_IP_LOCKOUT_LIMIT) {
ctx.set("Retry-After", String(env.LOGIN_LOCKOUT_SECONDS))
console.log(
`[auth] login blocked due to IP lockout ip=${ip} count=${count}`
)
return ctx.throw(429, "Too many login attempts. Try again later.")
}

return await next()
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import lockout from "../lockout"
import emailLockout from "../emailLockout"
import { cache } from "@budibase/backend-core"
import * as userSdk from "../../sdk/users"
import env from "../../environment"
Expand All @@ -15,7 +15,7 @@ jest.mock("../../sdk/users", () => ({
},
}))

describe("lockout middleware", () => {
describe("emailLockout middleware", () => {
let ctx: any
let next: jest.Mock

Expand All @@ -34,17 +34,17 @@ describe("lockout middleware", () => {
it("should call next if no email provided", async () => {
ctx.request.body = {}

await lockout(ctx, next)
await emailLockout(ctx, next)

expect(next).toHaveBeenCalled()
expect(ctx.throw).not.toHaveBeenCalled()
})

it("should call next if user not found", async () => {
ctx.request.body = { username: "test@example.com" }
;(userSdk.db.getUserByEmail as jest.Mock).mockResolvedValue(null)
;(userSdk.db.getUserByEmail as jest.Mock).mockResolvedValue(undefined)

await lockout(ctx, next)
await emailLockout(ctx, next)

expect(next).toHaveBeenCalled()
expect(ctx.throw).not.toHaveBeenCalled()
Expand All @@ -55,9 +55,9 @@ describe("lockout middleware", () => {
;(userSdk.db.getUserByEmail as jest.Mock).mockResolvedValue({
email: "test@example.com",
})
;(cache.get as jest.Mock).mockResolvedValue(null)
jest.mocked(cache.get).mockResolvedValue(undefined)

await lockout(ctx, next)
await emailLockout(ctx, next)

expect(next).toHaveBeenCalled()
expect(ctx.throw).not.toHaveBeenCalled()
Expand All @@ -68,16 +68,15 @@ describe("lockout middleware", () => {
;(userSdk.db.getUserByEmail as jest.Mock).mockResolvedValue({
email: "test@example.com",
})
;(cache.get as jest.Mock).mockResolvedValue("1")
jest.mocked(cache.get).mockResolvedValue("1")

// Mock ctx.throw to actually throw
ctx.throw = jest.fn().mockImplementation((status, message) => {
const error = new Error(message)
;(error as any).status = status
throw error
})

await expect(lockout(ctx, next)).rejects.toThrow(
await expect(emailLockout(ctx, next)).rejects.toThrow(
"Account temporarily locked. Try again later."
)

Expand Down
81 changes: 81 additions & 0 deletions packages/worker/src/middleware/tests/ipLockout.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import ipLockout from "../ipLockout"
import { cache } from "@budibase/backend-core"
import env from "../../environment"

jest.mock("@budibase/backend-core", () => ({
cache: {
increment: jest.fn(),
},
}))

describe("ipLockout middleware", () => {
let ctx: any
let next: jest.Mock

beforeEach(() => {
ctx = {
ip: "1.2.3.4",
set: jest.fn(),
throw: jest.fn(),
}
next = jest.fn()
jest.clearAllMocks()
})

it("should call next if no IP is present", async () => {
ctx.ip = ""

await ipLockout(ctx, next)

expect(next).toHaveBeenCalled()
expect(ctx.throw).not.toHaveBeenCalled()
})

it("should call next when count is below the limit", async () => {
jest.mocked(cache.increment).mockResolvedValue(1)

await ipLockout(ctx, next)

expect(cache.increment).toHaveBeenCalledWith(
"auth:login:ip:1.2.3.4",
env.LOGIN_LOCKOUT_SECONDS
)
expect(next).toHaveBeenCalled()
expect(ctx.throw).not.toHaveBeenCalled()
})

it("should call next when count equals the limit", async () => {
jest.mocked(cache.increment).mockResolvedValue(env.LOGIN_IP_LOCKOUT_LIMIT)

await ipLockout(ctx, next)

expect(next).toHaveBeenCalled()
expect(ctx.throw).not.toHaveBeenCalled()
})

it("should throw 429 with Retry-After when count exceeds the limit", async () => {
jest
.mocked(cache.increment)
.mockResolvedValue(env.LOGIN_IP_LOCKOUT_LIMIT + 1)

ctx.throw = jest.fn().mockImplementation((status, message) => {
const error = new Error(message)
;(error as any).status = status
throw error
})

await expect(ipLockout(ctx, next)).rejects.toThrow(
"Too many login attempts. Try again later."
)

expect(next).not.toHaveBeenCalled()
expect(ctx.set).toHaveBeenCalledWith(
"Retry-After",
String(env.LOGIN_LOCKOUT_SECONDS)
)
expect(ctx.throw).toHaveBeenCalledWith(
429,
"Too many login attempts. Try again later."
)
})
})
Loading