From 1276d40ff5c6908ac3547bb12332df03770a1496 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:52:13 +0000 Subject: [PATCH] fix(application): validate registry org ownership on application.update --- .../application-update-registry-fk.test.ts | 57 +++++++++++++++++++ .../registry-organization-guard.test.ts | 50 ++++++++++++++++ .../dokploy/server/api/routers/application.ts | 15 +++++ packages/server/src/services/registry.ts | 13 +++++ 4 files changed, 135 insertions(+) create mode 100644 apps/dokploy/__test__/registry/application-update-registry-fk.test.ts create mode 100644 apps/dokploy/__test__/registry/registry-organization-guard.test.ts diff --git a/apps/dokploy/__test__/registry/application-update-registry-fk.test.ts b/apps/dokploy/__test__/registry/application-update-registry-fk.test.ts new file mode 100644 index 000000000..dd5de6b54 --- /dev/null +++ b/apps/dokploy/__test__/registry/application-update-registry-fk.test.ts @@ -0,0 +1,57 @@ +import { apiUpdateApplication } from "@dokploy/server/db/schema"; +import { describe, expect, it } from "vitest"; + +const APP_ID = "app-1"; + +describe("apiUpdateApplication — registry FK columns", () => { + it("keeps registryId, buildRegistryId and rollbackRegistryId writable as strings", () => { + const r = apiUpdateApplication.safeParse({ + applicationId: APP_ID, + registryId: "r1", + buildRegistryId: "r2", + rollbackRegistryId: "r3", + }); + expect(r.success).toBe(true); + if (r.success) { + expect(r.data.registryId).toBe("r1"); + expect(r.data.buildRegistryId).toBe("r2"); + expect(r.data.rollbackRegistryId).toBe("r3"); + } + }); + + it("accepts null to clear the registry FKs (registry/rollback picker 'None')", () => { + const r = apiUpdateApplication.safeParse({ + applicationId: APP_ID, + registryId: null, + buildRegistryId: null, + rollbackRegistryId: null, + }); + expect(r.success).toBe(true); + if (r.success) { + expect(r.data.registryId).toBeNull(); + expect(r.data.buildRegistryId).toBeNull(); + expect(r.data.rollbackRegistryId).toBeNull(); + } + }); + + it("still omits serverId from the writable surface", () => { + const r = apiUpdateApplication.safeParse({ + applicationId: APP_ID, + serverId: "srv-1", + } as any); + expect(r.success).toBe(true); + if (r.success) { + expect((r.data as Record).serverId).toBeUndefined(); + expect("serverId" in r.data).toBe(false); + } + }); + + it("requires applicationId (rejects missing and empty)", () => { + expect(apiUpdateApplication.safeParse({ registryId: "r1" }).success).toBe( + false, + ); + expect(apiUpdateApplication.safeParse({ applicationId: "" }).success).toBe( + false, + ); + }); +}); diff --git a/apps/dokploy/__test__/registry/registry-organization-guard.test.ts b/apps/dokploy/__test__/registry/registry-organization-guard.test.ts new file mode 100644 index 000000000..59587beac --- /dev/null +++ b/apps/dokploy/__test__/registry/registry-organization-guard.test.ts @@ -0,0 +1,50 @@ +import { db } from "@dokploy/server/db"; +import { assertRegistryBelongsToOrganization } from "@dokploy/server/services/registry"; +import { TRPCError } from "@trpc/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// db is globally mocked in __test__/setup.ts; each test overrides +// db.query.registry.findFirst to model the row findRegistryById resolves. +const ORG = "org-1"; +const OTHER_ORG = "org-2"; +const REGISTRY_ID = "reg-1"; + +function mockRegistry(organizationId: string) { + vi.mocked(db.query.registry.findFirst).mockResolvedValue({ + registryId: REGISTRY_ID, + organizationId, + } as any); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("assertRegistryBelongsToOrganization", () => { + it("resolves when the registry belongs to the caller's organization", async () => { + mockRegistry(ORG); + await expect( + assertRegistryBelongsToOrganization(REGISTRY_ID, ORG), + ).resolves.toBeUndefined(); + }); + + it("rejects a registry from another organization with UNAUTHORIZED", async () => { + mockRegistry(OTHER_ORG); + const err = await assertRegistryBelongsToOrganization( + REGISTRY_ID, + ORG, + ).catch((e: unknown) => e); + expect(err).toBeInstanceOf(TRPCError); + expect((err as TRPCError).code).toBe("UNAUTHORIZED"); + expect((err as TRPCError).message).toBe( + "You are not authorized to use this registry", + ); + }); + + it("propagates NOT_FOUND when the referenced registry does not exist", async () => { + vi.mocked(db.query.registry.findFirst).mockResolvedValue(undefined as any); + await expect( + assertRegistryBelongsToOrganization("missing", ORG), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); +}); diff --git a/apps/dokploy/server/api/routers/application.ts b/apps/dokploy/server/api/routers/application.ts index 01a9534d9..334e7b3f7 100644 --- a/apps/dokploy/server/api/routers/application.ts +++ b/apps/dokploy/server/api/routers/application.ts @@ -1,4 +1,5 @@ import { + assertRegistryBelongsToOrganization, clearOldDeployments, createApplication, createDomain, @@ -774,6 +775,20 @@ export const applicationRouter = createTRPCRouter({ } } + const organizationId = ctx.session.activeOrganizationId; + for (const candidateRegistryId of [ + input.registryId, + input.rollbackRegistryId, + input.buildRegistryId, + ]) { + if (candidateRegistryId) { + await assertRegistryBelongsToOrganization( + candidateRegistryId, + organizationId, + ); + } + } + const { applicationId, ...rest } = input; const updateApp = await updateApplication(applicationId, { ...rest, diff --git a/packages/server/src/services/registry.ts b/packages/server/src/services/registry.ts index e395dd4c9..4d6d41dfa 100644 --- a/packages/server/src/services/registry.ts +++ b/packages/server/src/services/registry.ts @@ -198,6 +198,19 @@ export const findRegistryByIdWithCredentials = async (registryId: string) => { return registryResponse; }; +export const assertRegistryBelongsToOrganization = async ( + registryId: string, + organizationId: string, +) => { + const registryResult = await findRegistryById(registryId); + if (registryResult.organizationId !== organizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to use this registry", + }); + } +}; + export const findAllRegistryByOrganizationId = async ( organizationId: string, ) => {