mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-15 21:31:11 +00:00
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:
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user