diff --git a/lerna.json b/lerna.json index afcb33918b..d9d9e01bef 100644 --- a/lerna.json +++ b/lerna.json @@ -1,6 +1,6 @@ { "$schema": "node_modules/lerna/schemas/lerna-schema.json", - "version": "3.4.9", + "version": "3.4.11", "npmClient": "yarn", "concurrency": 20, "command": { diff --git a/packages/backend-core/src/utils/tests/utils.spec.ts b/packages/backend-core/src/utils/tests/utils.spec.ts index b5c6283ce2..c7c1e4334f 100644 --- a/packages/backend-core/src/utils/tests/utils.spec.ts +++ b/packages/backend-core/src/utils/tests/utils.spec.ts @@ -67,6 +67,15 @@ describe("utils", () => { }) }) + it("gets appId from query params", async () => { + const ctx = structures.koa.newContext() + const expected = db.generateAppID() + ctx.query = { appId: expected } + + const actual = await utils.getAppIdFromCtx(ctx) + expect(actual).toBe(expected) + }) + it("doesn't get appId from url when previewing", async () => { const ctx = structures.koa.newContext() const appId = db.generateAppID() diff --git a/packages/backend-core/src/utils/utils.ts b/packages/backend-core/src/utils/utils.ts index 7f2e25b6d4..fcec61179f 100644 --- a/packages/backend-core/src/utils/utils.ts +++ b/packages/backend-core/src/utils/utils.ts @@ -101,6 +101,11 @@ export async function getAppIdFromCtx(ctx: Ctx) { appId = confirmAppId(pathId) } + // look in queryParams + if (!appId && ctx.query?.appId) { + appId = confirmAppId(ctx.query?.appId as string) + } + // lookup using custom url - prod apps only // filter out the builder preview path which collides with the prod app path // to ensure we don't load all apps excessively diff --git a/packages/server/src/api/routes/backup.ts b/packages/server/src/api/routes/backup.ts index 94e4cf957f..e94aeb8874 100644 --- a/packages/server/src/api/routes/backup.ts +++ b/packages/server/src/api/routes/backup.ts @@ -2,12 +2,14 @@ import Router from "@koa/router" import * as controller from "../controllers/backup" import authorized from "../../middleware/authorized" import { permissions } from "@budibase/backend-core" +import ensureTenantAppOwnership from "../../middleware/ensureTenantAppOwnership" const router: Router = new Router() router.post( "/api/backups/export", authorized(permissions.BUILDER), + ensureTenantAppOwnership, controller.exportAppDump ) diff --git a/packages/server/src/middleware/ensureTenantAppOwnership.ts b/packages/server/src/middleware/ensureTenantAppOwnership.ts new file mode 100644 index 0000000000..23f35f5cb8 --- /dev/null +++ b/packages/server/src/middleware/ensureTenantAppOwnership.ts @@ -0,0 +1,19 @@ +import { tenancy, utils, context } from "@budibase/backend-core" +import { UserCtx } from "@budibase/types" + +async function ensureTenantAppOwnership(ctx: UserCtx, next: any) { + const appId = await utils.getAppIdFromCtx(ctx) + if (!appId) { + ctx.throw(400, "appId must be provided") + } + + const appTenantId = context.getTenantIDFromAppID(appId) + const tenantId = tenancy.getTenantId() + + if (appTenantId !== tenantId) { + ctx.throw(403, "Unauthorized") + } + await next() +} + +export default ensureTenantAppOwnership diff --git a/packages/server/src/middleware/tests/ensureTenantAppOwnership.spec.js b/packages/server/src/middleware/tests/ensureTenantAppOwnership.spec.js new file mode 100644 index 0000000000..5c500f8723 --- /dev/null +++ b/packages/server/src/middleware/tests/ensureTenantAppOwnership.spec.js @@ -0,0 +1,75 @@ +import ensureTenantAppOwnership from "../ensureTenantAppOwnership" +import { tenancy, utils } from "@budibase/backend-core" + +jest.mock("@budibase/backend-core", () => ({ + ...jest.requireActual("@budibase/backend-core"), + tenancy: { + getTenantId: jest.fn(), + }, + utils: { + getAppIdFromCtx: jest.fn(), + }, +})) + +class TestConfiguration { + constructor(appId = "tenant_1") { + this.next = jest.fn() + this.throw = jest.fn() + this.middleware = ensureTenantAppOwnership + + this.ctx = { + next: this.next, + throw: this.throw, + } + + utils.getAppIdFromCtx.mockResolvedValue(appId) + } + + async executeMiddleware() { + return this.middleware(this.ctx, this.next) + } + + afterEach() { + jest.clearAllMocks() + } +} + +describe("Ensure Tenant Ownership Middleware", () => { + let config + + beforeEach(() => { + config = new TestConfiguration() + }) + + afterEach(() => { + config.afterEach() + }) + + it("calls next() when appId matches tenant ID", async () => { + tenancy.getTenantId.mockReturnValue("tenant_1") + + await config.executeMiddleware() + + expect(utils.getAppIdFromCtx).toHaveBeenCalledWith(config.ctx) + expect(config.next).toHaveBeenCalled() + }) + + it("throws when tenant appId does not match tenant ID", async () => { + const appId = "app_dev_tenant3_fce449c4d75b4e4a9c7a6980d82a3e22" + utils.getAppIdFromCtx.mockResolvedValue(appId) + tenancy.getTenantId.mockReturnValue("tenant_2") + + await config.executeMiddleware() + + expect(utils.getAppIdFromCtx).toHaveBeenCalledWith(config.ctx) + expect(config.throw).toHaveBeenCalledWith(403, "Unauthorized") + }) + + it("throws 400 when appId is missing", async () => { + utils.getAppIdFromCtx.mockResolvedValue(null) + + await config.executeMiddleware() + + expect(config.throw).toHaveBeenCalledWith(400, "appId must be provided") + }) +})