Merge pull request #5070 from bunlongheng/fix/traefik-file-path-traversal

fix(settings): confine Traefik file read/write to the Traefik config directory
This commit is contained in:
Narciso E. Núñez Arias
2026-10-15 17:24:35 -04:00
committed by GitHub
2 changed files with 162 additions and 2 deletions
@@ -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<typeof settingsRouter.createCaller>[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,
);
});
});
+43 -2
View File
@@ -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) {