diff --git a/apps/dokploy/__test__/traefik/traefik-files-server-organization.test.ts b/apps/dokploy/__test__/traefik/traefik-files-server-organization.test.ts new file mode 100644 index 000000000..c9c2167b4 --- /dev/null +++ b/apps/dokploy/__test__/traefik/traefik-files-server-organization.test.ts @@ -0,0 +1,119 @@ +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// apiReadTraefikConfig validates against the real local paths(), which outside +// production live under the working directory. +const traefikPath = vi.hoisted(() => `${process.cwd()}/.docker/traefik`); + +const mocks = vi.hoisted(() => ({ + findServerById: vi.fn(), + readDirectory: vi.fn(), + readConfigInPath: vi.fn(), + writeTraefikConfigInPath: vi.fn(), +})); + +vi.mock("@dokploy/server/services/permission", () => ({ + checkPermission: vi.fn(async () => undefined), +})); + +vi.mock("@dokploy/server", () => ({ + IS_CLOUD: false, + paths: () => ({ MAIN_TRAEFIK_PATH: traefikPath }), + ...mocks, +})); + +vi.mock("@dokploy/server/lib/auth", () => ({ + validateRequest: vi.fn(), +})); + +vi.mock("@/server/api/utils/audit", () => ({ + audit: vi.fn(async () => undefined), +})); + +vi.mock("@/server/queues/queueSetup", () => ({ + cleanAllDeploymentQueue: vi.fn(), +})); + +vi.mock("@/server/utils/backup", () => ({ + removeJob: vi.fn(), + schedule: vi.fn(), +})); + +const { settingsRouter } = await import("@/server/api/routers/settings"); + +const caller = settingsRouter.createCaller({ + session: { activeOrganizationId: "org-a" }, + user: { id: "user-1", email: "user@example.com", role: "member" }, +} as Parameters[0]); + +const filePath = path.join(traefikPath, "dynamic/app.yml"); + +describe("traefik file endpoints scope remote servers to the active organization", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.findServerById.mockImplementation(async (serverId: string) => ({ + serverId, + organizationId: serverId === "server-a" ? "org-a" : "org-b", + })); + mocks.readDirectory.mockResolvedValue([]); + mocks.readConfigInPath.mockResolvedValue("http: {}"); + }); + + it("rejects listing a server from another organization", async () => { + await expect( + caller.readDirectories({ serverId: "server-b" }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + expect(mocks.readDirectory).not.toHaveBeenCalled(); + }); + + it("rejects writing to a server from another organization", async () => { + await expect( + caller.updateTraefikFile({ + path: filePath, + traefikConfig: "http: {}", + serverId: "server-b", + }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + expect(mocks.writeTraefikConfigInPath).not.toHaveBeenCalled(); + }); + + it("rejects reading from a server from another organization", async () => { + await expect( + caller.readTraefikFile({ path: filePath, serverId: "server-b" }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + expect(mocks.readConfigInPath).not.toHaveBeenCalled(); + }); + + it("allows servers of the active organization", async () => { + await caller.readDirectories({ serverId: "server-a" }); + await caller.readTraefikFile({ path: filePath, serverId: "server-a" }); + await caller.updateTraefikFile({ + path: filePath, + traefikConfig: "http: {}", + serverId: "server-a", + }); + + expect(mocks.readDirectory).toHaveBeenCalledWith(traefikPath, "server-a"); + expect(mocks.readConfigInPath).toHaveBeenCalledWith(filePath, "server-a"); + expect(mocks.writeTraefikConfigInPath).toHaveBeenCalledWith( + filePath, + "http: {}", + "server-a", + ); + }); + + it("does not look up a server when none is given", async () => { + await caller.readDirectories({}); + await caller.updateTraefikFile({ + path: filePath, + traefikConfig: "http: {}", + }); + + expect(mocks.findServerById).not.toHaveBeenCalled(); + expect(mocks.writeTraefikConfigInPath).toHaveBeenCalledWith( + filePath, + "http: {}", + undefined, + ); + }); +}); diff --git a/apps/dokploy/server/api/routers/settings.ts b/apps/dokploy/server/api/routers/settings.ts index db2951ae4..504d2ca8e 100644 --- a/apps/dokploy/server/api/routers/settings.ts +++ b/apps/dokploy/server/api/routers/settings.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { CLEANUP_CRON_JOB, checkGPUStatus, @@ -81,6 +82,32 @@ import { publicProcedure, } from "../trpc"; +/** + * Confines a caller-supplied Traefik file path to the Traefik configuration + * directory (MAIN_TRAEFIK_PATH). The read/write Traefik file endpoints only + * expose files listed under this directory, but the raw path was passed + * straight to the filesystem helpers, letting a user with the `traefikFiles` + * permission read or overwrite arbitrary files on the host (for example + * `/etc/dokploy/.env` or `~/.ssh/authorized_keys`) via an absolute path or + * `../` traversal. Reject anything that escapes the directory. + */ +const resolveTraefikFilePath = ( + filePath: string, + serverId?: string | null, +): string => { + const { MAIN_TRAEFIK_PATH } = paths(!!serverId); + const base = path.resolve(MAIN_TRAEFIK_PATH); + const resolved = path.resolve(base, filePath); + if (resolved !== base && !resolved.startsWith(`${base}${path.sep}`)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "Invalid path: file must be inside the Traefik configuration directory", + }); + } + return resolved; +}; + export const settingsRouter = createTRPCRouter({ getWebServerSettings: protectedProcedure.query(async () => { if (IS_CLOUD) { @@ -586,6 +613,12 @@ export const settingsRouter = createTRPCRouter({ .query(async ({ ctx, input }) => { try { await checkPermission(ctx, { traefikFiles: ["read"] }); + if (input?.serverId) { + const server = await findServerById(input.serverId); + if (server.organizationId !== ctx.session?.activeOrganizationId) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + } const { MAIN_TRAEFIK_PATH } = paths(!!input?.serverId); const result = await readDirectory(MAIN_TRAEFIK_PATH, input?.serverId); return result || []; @@ -598,8 +631,15 @@ export const settingsRouter = createTRPCRouter({ .input(apiModifyTraefikConfig) .mutation(async ({ input, ctx }) => { await checkPermission(ctx, { traefikFiles: ["write"] }); + if (input.serverId) { + const server = await findServerById(input.serverId); + if (server.organizationId !== ctx.session?.activeOrganizationId) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + } + const safePath = resolveTraefikFilePath(input.path, input?.serverId); await writeTraefikConfigInPath( - input.path, + safePath, input.traefikConfig, input?.serverId, ); @@ -624,7 +664,8 @@ export const settingsRouter = createTRPCRouter({ } } - return readConfigInPath(input.path, input.serverId); + const safePath = resolveTraefikFilePath(input.path, input.serverId); + return readConfigInPath(safePath, input.serverId); }), getIp: protectedProcedure.query(async () => { if (IS_CLOUD) {