From 1f1acacf585238f9a8f86f8b2a9ad1cb23834deb Mon Sep 17 00:00:00 2001 From: Bunlong Heng Date: Wed, 12 Aug 2026 17:38:48 -0400 Subject: [PATCH 1/2] fix(settings): confine Traefik file read/write to the Traefik config directory --- apps/dokploy/server/api/routers/settings.ts | 33 +++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/apps/dokploy/server/api/routers/settings.ts b/apps/dokploy/server/api/routers/settings.ts index db2951ae4..7ae1a757a 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) { @@ -598,8 +625,9 @@ export const settingsRouter = createTRPCRouter({ .input(apiModifyTraefikConfig) .mutation(async ({ input, ctx }) => { await checkPermission(ctx, { traefikFiles: ["write"] }); + const safePath = resolveTraefikFilePath(input.path, input?.serverId); await writeTraefikConfigInPath( - input.path, + safePath, input.traefikConfig, input?.serverId, ); @@ -624,7 +652,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) { From 136a09d0161e46fbab14a1872c7c7149a580ed89 Mon Sep 17 00:00:00 2001 From: Narciso Date: Thu, 15 Oct 2026 13:05:20 -0400 Subject: [PATCH 2/2] fix(settings): check server organization in traefik file endpoints --- .../traefik-files-server-organization.test.ts | 119 ++++++++++++++++++ apps/dokploy/server/api/routers/settings.ts | 12 ++ 2 files changed, 131 insertions(+) create mode 100644 apps/dokploy/__test__/traefik/traefik-files-server-organization.test.ts 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 7ae1a757a..504d2ca8e 100644 --- a/apps/dokploy/server/api/routers/settings.ts +++ b/apps/dokploy/server/api/routers/settings.ts @@ -613,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 || []; @@ -625,6 +631,12 @@ 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( safePath,