From 2fb4d3787a44449d0d7c9bf40f73d9f652ef9485 Mon Sep 17 00:00:00 2001 From: "detail-app[bot]" <180357370+detail-app[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 02:54:18 +0000 Subject: [PATCH] fix(api): restore organization ownership checks in service move procedures --- .../permissions/service-move-idor.test.ts | 421 ++++++++++++++++++ .../dokploy/server/api/routers/application.ts | 24 + apps/dokploy/server/api/routers/compose.ts | 24 + apps/dokploy/server/api/routers/libsql.ts | 24 + apps/dokploy/server/api/routers/mariadb.ts | 24 + apps/dokploy/server/api/routers/mongo.ts | 24 + apps/dokploy/server/api/routers/mysql.ts | 24 + apps/dokploy/server/api/routers/postgres.ts | 24 + apps/dokploy/server/api/routers/redis.ts | 24 + 9 files changed, 613 insertions(+) create mode 100644 apps/dokploy/__test__/permissions/service-move-idor.test.ts diff --git a/apps/dokploy/__test__/permissions/service-move-idor.test.ts b/apps/dokploy/__test__/permissions/service-move-idor.test.ts new file mode 100644 index 000000000..52c712351 --- /dev/null +++ b/apps/dokploy/__test__/permissions/service-move-idor.test.ts @@ -0,0 +1,421 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Hoisted mock functions for the collaborators the `move` procedures depend on. +const mockFindLibsqlById = vi.hoisted(() => vi.fn()); +const mockFindPostgresById = vi.hoisted(() => vi.fn()); +const mockFindRedisById = vi.hoisted(() => vi.fn()); +const mockFindMongoById = vi.hoisted(() => vi.fn()); +const mockFindMySqlById = vi.hoisted(() => vi.fn()); +const mockFindMariadbById = vi.hoisted(() => vi.fn()); +const mockFindComposeById = vi.hoisted(() => vi.fn()); +const mockFindApplicationById = vi.hoisted(() => vi.fn()); +const mockFindEnvironmentById = vi.hoisted(() => vi.fn()); +const mockCheckServicePermissionAndAccess = vi.hoisted(() => vi.fn()); +const mockDbUpdate = vi.hoisted(() => vi.fn()); +const mockAudit = vi.hoisted(() => vi.fn()); + +// Preserve the real `@dokploy/server` barrel (so every router's named import +// resolves) but override the finders that `move` uses to assert org ownership. +vi.mock("@dokploy/server", async (importOriginal) => { + const actual: Record = await importOriginal(); + return { + ...actual, + findLibsqlById: mockFindLibsqlById, + findPostgresById: mockFindPostgresById, + findRedisById: mockFindRedisById, + findMongoById: mockFindMongoById, + findMySqlById: mockFindMySqlById, + findMariadbById: mockFindMariadbById, + findComposeById: mockFindComposeById, + findApplicationById: mockFindApplicationById, + findEnvironmentById: mockFindEnvironmentById, + }; +}); + +vi.mock("@dokploy/server/services/permission", async (importOriginal) => { + const actual: Record = await importOriginal(); + return { + ...actual, + checkServicePermissionAndAccess: mockCheckServicePermissionAndAccess, + }; +}); + +// Avoid a real postgres connection from the apps/dokploy-local db module. +vi.mock("@/server/db", () => ({ + db: { update: mockDbUpdate }, +})); + +// The sibling routers (postgres, redis, ...) import `db` from the server +// package's db module instead of the apps-local one. Reuse the global +// setup mock's shape (so unrelated drizzle/better-auth init keeps working) +// but route `update` through the controllable hoisted mock so each `move` +// happy-path can return a precise row. +vi.mock("@dokploy/server/db", () => { + // A chainable built on a real Promise (no user-defined `then`): any + // terminal `await` resolves to [], satisfying the drizzle chains the + // server barrel runs at import (e.g. better-auth trusted-origins init) + // without noise and without tripping the `noThenProperty` lint rule. + type Chainable = Promise & { + [key: string]: (...args: unknown[]) => unknown; + }; + const chainable = () => { + const p = Promise.resolve([]) as unknown as Chainable; + const next = () => chainable(); + p.from = next; + p.innerJoin = next; + p.leftJoin = next; + p.where = next; + p.limit = next; + p.orderBy = next; + p.groupBy = next; + p.having = next; + p.set = next; + p.values = next; + p.returning = () => Promise.resolve([{}]); + return p; + }; + const tableMock = { + findFirst: () => Promise.resolve(undefined), + findMany: () => Promise.resolve([]), + }; + return { + db: { + select: () => chainable(), + insert: () => ({ + values: () => ({ returning: () => Promise.resolve([{}]) }), + }), + update: mockDbUpdate, + delete: () => chainable(), + query: new Proxy({} as Record, { + get: () => tableMock, + }), + }, + dbUrl: "postgres://mock:mock@localhost:5432/mock", + }; +}); + +vi.mock("@/server/api/utils/audit", () => ({ + audit: mockAudit, +})); + +import { applicationRouter } from "@/server/api/routers/application"; +import { composeRouter } from "@/server/api/routers/compose"; +import { libsqlRouter } from "@/server/api/routers/libsql"; +import { mariadbRouter } from "@/server/api/routers/mariadb"; +import { mongoRouter } from "@/server/api/routers/mongo"; +import { mysqlRouter } from "@/server/api/routers/mysql"; +import { postgresRouter } from "@/server/api/routers/postgres"; +import { redisRouter } from "@/server/api/routers/redis"; + +const buildCtx = (organizationId: string) => ({ + user: { + id: "user-1", + email: "u@example.com", + role: "owner" as const, + ownerId: "user-1", + enableEnterpriseFeatures: false, + isValidEnterpriseLicense: false, + }, + session: { + activeOrganizationId: organizationId, + }, +}); + +// `db.update(table).set(...).where(...).returning()` resolves to a row array; +// the procedure then does `.then((res) => res[0])`. Mock that chain once. +const mockUpdateChainOnce = (row: Record) => { + mockDbUpdate.mockReturnValueOnce({ + set: () => ({ + where: () => ({ + returning: () => Promise.resolve([row]), + }), + }), + }); +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockCheckServicePermissionAndAccess.mockResolvedValue(undefined); + mockDbUpdate.mockImplementation(() => { + throw new Error("db.update should not be reached"); + }); +}); + +describe("libsql.move — cross-organization reparenting (IDOR)", () => { + const callMove = (orgId: string) => + libsqlRouter.createCaller(buildCtx(orgId) as never); + + it("rejects when the source libsql belongs to another organization", async () => { + mockFindLibsqlById.mockResolvedValue({ + libsqlId: "L", + appName: "a", + environment: { project: { organizationId: "org-other" } }, + }); + + await expect( + callMove("org-1").move({ libsqlId: "L", targetEnvironmentId: "E" }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + + // The source check must short-circuit before fetching the target or + // touching the database. + expect(mockFindEnvironmentById).not.toHaveBeenCalled(); + expect(mockDbUpdate).not.toHaveBeenCalled(); + expect(mockAudit).not.toHaveBeenCalled(); + }); + + it("rejects when the target environment belongs to another organization", async () => { + mockFindLibsqlById.mockResolvedValue({ + libsqlId: "L", + appName: "a", + environment: { project: { organizationId: "org-1" } }, + }); + mockFindEnvironmentById.mockResolvedValue({ + environmentId: "E", + project: { organizationId: "org-other" }, + }); + + await expect( + callMove("org-1").move({ libsqlId: "L", targetEnvironmentId: "E" }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + + expect(mockDbUpdate).not.toHaveBeenCalled(); + expect(mockAudit).not.toHaveBeenCalled(); + }); + + it("moves the service when both source and target are in the active org", async () => { + const moved = { libsqlId: "L", appName: "a", environmentId: "E" }; + mockFindLibsqlById.mockResolvedValue({ + libsqlId: "L", + appName: "a", + environment: { project: { organizationId: "org-1" } }, + }); + mockFindEnvironmentById.mockResolvedValue({ + environmentId: "E", + project: { organizationId: "org-1" }, + }); + mockUpdateChainOnce(moved); + + const result = await callMove("org-1").move({ + libsqlId: "L", + targetEnvironmentId: "E", + }); + + expect(result).toEqual(moved); + expect(mockDbUpdate).toHaveBeenCalledTimes(1); + expect(mockFindEnvironmentById).toHaveBeenCalledWith("E"); + expect(mockAudit).toHaveBeenCalledTimes(1); + }); + + it("still enforces checkServicePermissionAndAccess before the org checks", async () => { + mockCheckServicePermissionAndAccess.mockRejectedValueOnce( + new Error("permission denied"), + ); + + await expect( + callMove("org-1").move({ libsqlId: "L", targetEnvironmentId: "E" }), + ).rejects.toThrow("permission denied"); + + expect(mockFindLibsqlById).not.toHaveBeenCalled(); + expect(mockDbUpdate).not.toHaveBeenCalled(); + }); + + it("detects a same-name organization id collision only by exact match", async () => { + // Distinct orgs must not be treated as equal even when the caller's + // active org and the source org are both non-empty strings. + mockFindLibsqlById.mockResolvedValue({ + libsqlId: "L", + appName: "a", + environment: { project: { organizationId: "ORG-1" } }, + }); + + await expect( + callMove("org-1").move({ libsqlId: "L", targetEnvironmentId: "E" }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + }); +}); + +describe("postgres.move — same cross-organization guard", () => { + const callMove = (orgId: string) => + postgresRouter.createCaller(buildCtx(orgId) as never); + + it("rejects when the source postgres belongs to another organization", async () => { + mockFindPostgresById.mockResolvedValue({ + postgresId: "P", + appName: "a", + environment: { project: { organizationId: "org-other" } }, + }); + + await expect( + callMove("org-1").move({ postgresId: "P", targetEnvironmentId: "E" }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + + expect(mockFindEnvironmentById).not.toHaveBeenCalled(); + expect(mockDbUpdate).not.toHaveBeenCalled(); + }); + + it("rejects when the target environment belongs to another organization", async () => { + mockFindPostgresById.mockResolvedValue({ + postgresId: "P", + appName: "a", + environment: { project: { organizationId: "org-1" } }, + }); + mockFindEnvironmentById.mockResolvedValue({ + environmentId: "E", + project: { organizationId: "org-other" }, + }); + + await expect( + callMove("org-1").move({ postgresId: "P", targetEnvironmentId: "E" }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + + expect(mockDbUpdate).not.toHaveBeenCalled(); + }); + + it("moves the service when both source and target are in the active org", async () => { + const moved = { postgresId: "P", appName: "a", environmentId: "E" }; + mockFindPostgresById.mockResolvedValue({ + postgresId: "P", + appName: "a", + environment: { project: { organizationId: "org-1" } }, + }); + mockFindEnvironmentById.mockResolvedValue({ + environmentId: "E", + project: { organizationId: "org-1" }, + }); + mockUpdateChainOnce(moved); + + const result = await callMove("org-1").move({ + postgresId: "P", + targetEnvironmentId: "E", + }); + + expect(result).toEqual(moved); + expect(mockDbUpdate).toHaveBeenCalledTimes(1); + expect(mockAudit).toHaveBeenCalledTimes(1); + }); +}); + +// Shared factory that exercises G1–G3 (source cross-org reject, target +// cross-org reject, same-org succeed) for one router's `move`. Keeps the six +// sibling routers covered without copy-pasting the three-case body each time. +const runCrossOrgGuardCases = (options: { + name: string; + router: ReturnType extends never + ? never + : { + createCaller: (ctx: never) => { + move: (input: unknown) => Promise; + }; + }; + idKey: string; + findById: ReturnType; + movedRow: Record; +}) => { + const { name, router, idKey, findById, movedRow } = options; + const callMove = (orgId: string) => + router.createCaller(buildCtx(orgId) as never); + + describe(`${name}.move — same cross-organization guard`, () => { + it("rejects when the source service belongs to another organization", async () => { + findById.mockResolvedValue({ + [idKey]: "S", + environment: { project: { organizationId: "org-other" } }, + }); + + await expect( + callMove("org-1").move({ [idKey]: "S", targetEnvironmentId: "E" }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + + expect(mockFindEnvironmentById).not.toHaveBeenCalled(); + expect(mockDbUpdate).not.toHaveBeenCalled(); + expect(mockAudit).not.toHaveBeenCalled(); + }); + + it("rejects when the target environment belongs to another organization", async () => { + findById.mockResolvedValue({ + [idKey]: "S", + environment: { project: { organizationId: "org-1" } }, + }); + mockFindEnvironmentById.mockResolvedValue({ + environmentId: "E", + project: { organizationId: "org-other" }, + }); + + await expect( + callMove("org-1").move({ [idKey]: "S", targetEnvironmentId: "E" }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + + expect(mockDbUpdate).not.toHaveBeenCalled(); + expect(mockAudit).not.toHaveBeenCalled(); + }); + + it("moves the service when both source and target are in the active org", async () => { + findById.mockResolvedValue({ + [idKey]: "S", + environment: { project: { organizationId: "org-1" } }, + }); + mockFindEnvironmentById.mockResolvedValue({ + environmentId: "E", + project: { organizationId: "org-1" }, + }); + mockUpdateChainOnce(movedRow); + + const result = await callMove("org-1").move({ + [idKey]: "S", + targetEnvironmentId: "E", + }); + + expect(result).toEqual(movedRow); + expect(mockDbUpdate).toHaveBeenCalledTimes(1); + expect(mockAudit).toHaveBeenCalledTimes(1); + }); + }); +}; + +runCrossOrgGuardCases({ + name: "redis", + router: redisRouter as never, + idKey: "redisId", + findById: mockFindRedisById, + movedRow: { redisId: "S", appName: "a", environmentId: "E" }, +}); + +runCrossOrgGuardCases({ + name: "mongo", + router: mongoRouter as never, + idKey: "mongoId", + findById: mockFindMongoById, + movedRow: { mongoId: "S", appName: "a", environmentId: "E" }, +}); + +runCrossOrgGuardCases({ + name: "mysql", + router: mysqlRouter as never, + idKey: "mysqlId", + findById: mockFindMySqlById, + movedRow: { mysqlId: "S", appName: "a", environmentId: "E" }, +}); + +runCrossOrgGuardCases({ + name: "mariadb", + router: mariadbRouter as never, + idKey: "mariadbId", + findById: mockFindMariadbById, + movedRow: { mariadbId: "S", appName: "a", environmentId: "E" }, +}); + +runCrossOrgGuardCases({ + name: "compose", + router: composeRouter as never, + idKey: "composeId", + findById: mockFindComposeById, + movedRow: { composeId: "S", name: "a", environmentId: "E" }, +}); + +runCrossOrgGuardCases({ + name: "application", + router: applicationRouter as never, + idKey: "applicationId", + findById: mockFindApplicationById, + movedRow: { applicationId: "S", appName: "a", environmentId: "E" }, +}); diff --git a/apps/dokploy/server/api/routers/application.ts b/apps/dokploy/server/api/routers/application.ts index 01a9534d9..26a4588d0 100644 --- a/apps/dokploy/server/api/routers/application.ts +++ b/apps/dokploy/server/api/routers/application.ts @@ -1018,6 +1018,30 @@ export const applicationRouter = createTRPCRouter({ service: ["create"], }); + const application = await findApplicationById(input.applicationId); + if ( + application.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to move this application", + }); + } + + const targetEnvironment = await findEnvironmentById( + input.targetEnvironmentId, + ); + if ( + targetEnvironment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to move to this environment", + }); + } + const updatedApplication = await db .update(applications) .set({ diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts index 1de66824d..0dc2d00b4 100644 --- a/apps/dokploy/server/api/routers/compose.ts +++ b/apps/dokploy/server/api/routers/compose.ts @@ -786,6 +786,30 @@ export const composeRouter = createTRPCRouter({ service: ["create"], }); + const compose = await findComposeById(input.composeId); + if ( + compose.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to move this compose", + }); + } + + const targetEnvironment = await findEnvironmentById( + input.targetEnvironmentId, + ); + if ( + targetEnvironment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to move to this environment", + }); + } + const updatedCompose = await db .update(composeTable) .set({ diff --git a/apps/dokploy/server/api/routers/libsql.ts b/apps/dokploy/server/api/routers/libsql.ts index 3fdfcf55b..19a0c9687 100644 --- a/apps/dokploy/server/api/routers/libsql.ts +++ b/apps/dokploy/server/api/routers/libsql.ts @@ -433,6 +433,30 @@ export const libsqlRouter = createTRPCRouter({ service: ["create"], }); + const libsql = await findLibsqlById(input.libsqlId); + if ( + libsql.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to move this libsql", + }); + } + + const targetEnvironment = await findEnvironmentById( + input.targetEnvironmentId, + ); + if ( + targetEnvironment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to move to this environment", + }); + } + const updatedLibsql = await db .update(libsqlTable) .set({ diff --git a/apps/dokploy/server/api/routers/mariadb.ts b/apps/dokploy/server/api/routers/mariadb.ts index 98932f8af..5de26c830 100644 --- a/apps/dokploy/server/api/routers/mariadb.ts +++ b/apps/dokploy/server/api/routers/mariadb.ts @@ -460,6 +460,30 @@ export const mariadbRouter = createTRPCRouter({ service: ["create"], }); + const mariadb = await findMariadbById(input.mariadbId); + if ( + mariadb.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to move this mariadb", + }); + } + + const targetEnvironment = await findEnvironmentById( + input.targetEnvironmentId, + ); + if ( + targetEnvironment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to move to this environment", + }); + } + const updatedMariadb = await db .update(mariadbTable) .set({ diff --git a/apps/dokploy/server/api/routers/mongo.ts b/apps/dokploy/server/api/routers/mongo.ts index 42b410785..03a5228e8 100644 --- a/apps/dokploy/server/api/routers/mongo.ts +++ b/apps/dokploy/server/api/routers/mongo.ts @@ -475,6 +475,30 @@ export const mongoRouter = createTRPCRouter({ service: ["create"], }); + const mongo = await findMongoById(input.mongoId); + if ( + mongo.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to move this mongo", + }); + } + + const targetEnvironment = await findEnvironmentById( + input.targetEnvironmentId, + ); + if ( + targetEnvironment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to move to this environment", + }); + } + const updatedMongo = await db .update(mongoTable) .set({ diff --git a/apps/dokploy/server/api/routers/mysql.ts b/apps/dokploy/server/api/routers/mysql.ts index c315ec5d4..e54b513e0 100644 --- a/apps/dokploy/server/api/routers/mysql.ts +++ b/apps/dokploy/server/api/routers/mysql.ts @@ -478,6 +478,30 @@ export const mysqlRouter = createTRPCRouter({ service: ["create"], }); + const mysql = await findMySqlById(input.mysqlId); + if ( + mysql.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to move this mysql", + }); + } + + const targetEnvironment = await findEnvironmentById( + input.targetEnvironmentId, + ); + if ( + targetEnvironment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to move to this environment", + }); + } + const updatedMysql = await db .update(mysqlTable) .set({ diff --git a/apps/dokploy/server/api/routers/postgres.ts b/apps/dokploy/server/api/routers/postgres.ts index 97e398123..9b2351589 100644 --- a/apps/dokploy/server/api/routers/postgres.ts +++ b/apps/dokploy/server/api/routers/postgres.ts @@ -481,6 +481,30 @@ export const postgresRouter = createTRPCRouter({ service: ["create"], }); + const postgres = await findPostgresById(input.postgresId); + if ( + postgres.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to move this postgres", + }); + } + + const targetEnvironment = await findEnvironmentById( + input.targetEnvironmentId, + ); + if ( + targetEnvironment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to move to this environment", + }); + } + const updatedPostgres = await db .update(postgresTable) .set({ diff --git a/apps/dokploy/server/api/routers/redis.ts b/apps/dokploy/server/api/routers/redis.ts index fb0d8301c..a161147ed 100644 --- a/apps/dokploy/server/api/routers/redis.ts +++ b/apps/dokploy/server/api/routers/redis.ts @@ -462,6 +462,30 @@ export const redisRouter = createTRPCRouter({ service: ["create"], }); + const redis = await findRedisById(input.redisId); + if ( + redis.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to move this redis", + }); + } + + const targetEnvironment = await findEnvironmentById( + input.targetEnvironmentId, + ); + if ( + targetEnvironment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to move to this environment", + }); + } + const updatedRedis = await db .update(redisTable) .set({