Skip to content

Commit b818d66

Browse files
authored
Merge pull request #3733 from Dokploy/fix/Remote-Code-Execution-through-Path-Traversal
feat(tests): add unit tests for readValidDirectory function to valida…
2 parents 06fd561 + 1302d70 commit b818d66

File tree

8 files changed

+298
-18
lines changed

8 files changed

+298
-18
lines changed

apps/dokploy/__test__/drop/drop.test.ts

Lines changed: 175 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,18 @@ import { paths } from "@dokploy/server/constants";
66
import AdmZip from "adm-zip";
77
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
88

9+
const OUTPUT_BASE = "./__test__/drop/zips/output";
910
const { APPLICATIONS_PATH } = paths();
1011
vi.mock("@dokploy/server/constants", async (importOriginal) => {
1112
const actual = await importOriginal();
1213
return {
1314
// @ts-ignore
1415
...actual,
1516
paths: () => ({
16-
APPLICATIONS_PATH: "./__test__/drop/zips/output",
17+
// @ts-ignore
18+
...actual.paths(),
19+
BASE_PATH: OUTPUT_BASE,
20+
APPLICATIONS_PATH: OUTPUT_BASE,
1721
}),
1822
};
1923
});
@@ -150,6 +154,176 @@ const baseApp: ApplicationNested = {
150154
ulimitsSwarm: null,
151155
};
152156

157+
/**
158+
* GHSA-66v7-g3fh-47h3: Remote Code Execution through Path Traversal.
159+
* Validates the exact PoC: ZIP with path traversal entry ../../../../../etc/cron.d/malicious-cron
160+
* plus cover files (package.json, index.js). unzipDrop must reject and never write outside output.
161+
*/
162+
describe("GHSA-66v7-g3fh-47h3 path traversal RCE", () => {
163+
beforeAll(async () => {
164+
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
165+
});
166+
afterAll(async () => {
167+
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
168+
});
169+
170+
it("rejects PoC ZIP: traversal ../../../../../etc/cron.d/malicious-cron + package.json + index.js", async () => {
171+
baseApp.appName = "ghsa-rce";
172+
// PoC payload: same entry name as advisory (Python zipfile keeps it; AdmZip normalizes on add → use placeholder + replace)
173+
const traversalEntry = "../../../../../etc/cron.d/malicious-cron";
174+
const cronPayload = "* * * * * root id\n";
175+
const placeholder = "x".repeat(traversalEntry.length);
176+
const zip = new AdmZip();
177+
zip.addFile(
178+
"package.json",
179+
Buffer.from('{"name": "app", "version": "1.0.0"}'),
180+
);
181+
zip.addFile("index.js", Buffer.from('console.log("Application");'));
182+
zip.addFile(placeholder, Buffer.from(cronPayload));
183+
let buf = Buffer.from(zip.toBuffer());
184+
buf = Buffer.from(
185+
buf.toString("binary").split(placeholder).join(traversalEntry),
186+
"binary",
187+
);
188+
const file = new File([buf as unknown as ArrayBuffer], "exploit.zip");
189+
await expect(unzipDrop(file, baseApp)).rejects.toThrow(
190+
/Path traversal detected.*resolved path escapes output directory/,
191+
);
192+
});
193+
});
194+
195+
describe("security: existing symlink escape", () => {
196+
beforeAll(async () => {
197+
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
198+
});
199+
200+
afterAll(async () => {
201+
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
202+
});
203+
204+
it("should NOT write outside base when directory is a symlink", async () => {
205+
const appName = "symlink-existing";
206+
const output = path.join(APPLICATIONS_PATH, appName, "code");
207+
await fs.mkdir(output, { recursive: true });
208+
209+
// outside target (attacker wants to write here)
210+
const outside = path.join(APPLICATIONS_PATH, "..", "outside");
211+
await fs.mkdir(outside, { recursive: true });
212+
213+
// attacker-controlled symlink inside project
214+
await fs.symlink(outside, path.join(output, "logs"));
215+
216+
// zip looks totally harmless
217+
const zip = new AdmZip();
218+
zip.addFile("logs/pwned.txt", Buffer.from("owned"));
219+
220+
const file = new File([zip.toBuffer() as any], "exploit.zip");
221+
222+
await unzipDrop(file, { ...baseApp, appName });
223+
224+
// if vulnerable -> file exists outside sandbox
225+
const escaped = await fs
226+
.readFile(path.join(outside, "pwned.txt"), "utf8")
227+
.then(() => true)
228+
.catch(() => false);
229+
230+
expect(escaped).toBe(false);
231+
});
232+
});
233+
234+
describe("security: zip symlink entry blocked", () => {
235+
beforeAll(async () => {
236+
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
237+
});
238+
239+
afterAll(async () => {
240+
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
241+
});
242+
243+
it("rejects zip containing real symlink entry", async () => {
244+
const appName = "zip-symlink";
245+
246+
const zipBuffer = await fs.readFile(
247+
path.join(__dirname, "./zips/payload/symlink-entry.zip"),
248+
);
249+
250+
const file = new File([zipBuffer as any], "exploit.zip");
251+
252+
await expect(unzipDrop(file, { ...baseApp, appName })).rejects.toThrow(
253+
/Dangerous node entries are not allowed/,
254+
);
255+
});
256+
});
257+
258+
describe("unzipDrop path under output (no traversal)", () => {
259+
beforeAll(async () => {
260+
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
261+
});
262+
afterAll(async () => {
263+
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
264+
});
265+
266+
it("allows entry etc/cron.d/malicious-cron when under output (no path traversal)", async () => {
267+
baseApp.appName = "cron-under-output";
268+
const zip = new AdmZip();
269+
zip.addFile(
270+
"etc/cron.d/malicious-cron",
271+
Buffer.from("* * * * * root id\n"),
272+
);
273+
zip.addFile("package.json", Buffer.from('{"name":"app"}'));
274+
const file = new File(
275+
[zip.toBuffer() as unknown as ArrayBuffer],
276+
"app.zip",
277+
);
278+
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
279+
await unzipDrop(file, baseApp);
280+
const content = await fs.readFile(
281+
path.join(outputPath, "etc/cron.d/malicious-cron"),
282+
"utf8",
283+
);
284+
expect(content).toBe("* * * * * root id\n");
285+
});
286+
});
287+
288+
describe("security: traversal inside BASE_PATH (sandbox escape)", () => {
289+
beforeAll(async () => {
290+
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
291+
});
292+
293+
afterAll(async () => {
294+
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
295+
});
296+
297+
it("should NOT allow writing outside application directory but inside BASE_PATH", async () => {
298+
const appName = "sandbox-escape";
299+
300+
const base = APPLICATIONS_PATH.replace("/applications", "");
301+
const output = path.join(APPLICATIONS_PATH, appName, "code");
302+
303+
await fs.mkdir(output, { recursive: true });
304+
305+
// attacker writes into traefik config inside base
306+
const zip = new AdmZip();
307+
zip.addFile(
308+
"../../../traefik/dynamic/evil.yml",
309+
Buffer.from("pwned: true"),
310+
);
311+
312+
const file = new File([zip.toBuffer() as any], "exploit.zip");
313+
314+
await unzipDrop(file, { ...baseApp, appName });
315+
316+
const escapedPath = path.join(base, "traefik/dynamic/evil.yml");
317+
318+
const exists = await fs
319+
.readFile(escapedPath)
320+
.then(() => true)
321+
.catch(() => false);
322+
323+
expect(exists).toBe(false);
324+
});
325+
});
326+
153327
describe("unzipDrop using real zip files", () => {
154328
// const { APPLICATIONS_PATH } = paths();
155329
beforeAll(async () => {
@@ -166,14 +340,12 @@ describe("unzipDrop using real zip files", () => {
166340
try {
167341
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
168342
const zip = new AdmZip("./__test__/drop/zips/single-file.zip");
169-
console.log(`Output Path: ${outputPath}`);
170343
const zipBuffer = zip.toBuffer() as Buffer<ArrayBuffer>;
171344
const file = new File([zipBuffer], "single.zip");
172345
await unzipDrop(file, baseApp);
173346
const files = await fs.readdir(outputPath, { withFileTypes: true });
174347
expect(files.some((f) => f.name === "test.txt")).toBe(true);
175348
} catch (err) {
176-
console.log(err);
177349
} finally {
178350
}
179351
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/etc/passwd
169 Bytes
Binary file not shown.
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import path from "node:path";
2+
import { describe, expect, it, vi } from "vitest";
3+
4+
const BASE = "/base";
5+
6+
vi.mock("@dokploy/server/constants", async (importOriginal) => {
7+
const actual =
8+
await importOriginal<typeof import("@dokploy/server/constants")>();
9+
return {
10+
...actual,
11+
paths: () => ({
12+
...actual.paths(),
13+
BASE_PATH: BASE,
14+
LOGS_PATH: `${BASE}/logs`,
15+
APPLICATIONS_PATH: `${BASE}/applications`,
16+
}),
17+
};
18+
});
19+
20+
// Import after mock so paths() uses our BASE
21+
const { readValidDirectory } = await import("@dokploy/server");
22+
23+
describe("readValidDirectory (path traversal)", () => {
24+
it("returns true when directory is exactly BASE_PATH", () => {
25+
expect(readValidDirectory(BASE)).toBe(true);
26+
expect(readValidDirectory(path.resolve(BASE))).toBe(true);
27+
});
28+
29+
it("returns true when directory is under BASE_PATH", () => {
30+
expect(readValidDirectory(`${BASE}/logs`)).toBe(true);
31+
expect(readValidDirectory(`${BASE}/logs/app/foo.log`)).toBe(true);
32+
expect(readValidDirectory(`${BASE}/applications/myapp/code`)).toBe(true);
33+
});
34+
35+
it("returns false for path traversal escaping base (absolute)", () => {
36+
expect(readValidDirectory("/etc/passwd")).toBe(false);
37+
expect(readValidDirectory("/etc/cron.d/malicious")).toBe(false);
38+
expect(readValidDirectory("/tmp/outside")).toBe(false);
39+
});
40+
41+
it("returns false when resolved path escapes base via ..", () => {
42+
// Resolved: /etc/passwd (outside /base)
43+
expect(readValidDirectory(`${BASE}/../etc/passwd`)).toBe(false);
44+
expect(readValidDirectory(`${BASE}/logs/../../etc/passwd`)).toBe(false);
45+
expect(readValidDirectory(`${BASE}/..`)).toBe(false);
46+
});
47+
48+
it("returns true when .. stays within base", () => {
49+
// e.g. /base/logs/../applications -> /base/applications (still under /base)
50+
expect(readValidDirectory(`${BASE}/logs/../applications`)).toBe(true);
51+
expect(readValidDirectory(`${BASE}/foo/../bar`)).toBe(true);
52+
});
53+
54+
it("accepts serverId for remote base path", () => {
55+
// With our mock, serverId doesn't change BASE_PATH; just ensure it doesn't throw
56+
expect(readValidDirectory(BASE, "server-1")).toBe(true);
57+
expect(readValidDirectory("/etc/passwd", "server-1")).toBe(false);
58+
});
59+
60+
it("returns false for null/undefined-like paths that resolve outside", () => {
61+
// Paths that might resolve to cwd or root
62+
expect(readValidDirectory(".")).toBe(false);
63+
expect(readValidDirectory("..")).toBe(false);
64+
});
65+
66+
it("returns true for BASE_PATH with trailing slash or double slashes under base", () => {
67+
expect(readValidDirectory(`${BASE}/`)).toBe(true);
68+
expect(readValidDirectory(`${BASE}//logs`)).toBe(true);
69+
expect(readValidDirectory(`${BASE}/applications///myapp/code`)).toBe(true);
70+
});
71+
72+
it("returns false when path looks like base but is a sibling or prefix", () => {
73+
expect(readValidDirectory("/base-evil")).toBe(false);
74+
expect(readValidDirectory("/bas")).toBe(false);
75+
expect(readValidDirectory(`${BASE}/../base-evil`)).toBe(false);
76+
});
77+
78+
it("returns false for empty string (resolves to cwd)", () => {
79+
expect(readValidDirectory("")).toBe(false);
80+
});
81+
});

apps/dokploy/server/wss/listen-deployment.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { spawn } from "node:child_process";
22
import type http from "node:http";
33
import { findServerById, IS_CLOUD, validateRequest } from "@dokploy/server";
4+
import { readValidDirectory } from "@dokploy/server/wss/utils";
45
import { Client } from "ssh2";
56
import { WebSocketServer } from "ws";
6-
import { readValidDirectory } from "./utils";
77

88
export const setupDeploymentLogsWebSocketServer = (
99
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,

apps/dokploy/server/wss/utils.ts

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -32,20 +32,6 @@ export const isValidShell = (shell: string): boolean => {
3232
return allowedShells.includes(shell);
3333
};
3434

35-
export const readValidDirectory = (
36-
directory: string,
37-
serverId?: string | null,
38-
) => {
39-
const { BASE_PATH } = paths(!!serverId);
40-
41-
const resolvedBase = path.resolve(BASE_PATH);
42-
const resolvedDir = path.resolve(directory);
43-
44-
return (
45-
resolvedDir === resolvedBase ||
46-
resolvedDir.startsWith(resolvedBase + path.sep)
47-
);
48-
};
4935
export const getShell = () => {
5036
if (IS_CLOUD) {
5137
return "NO_AVAILABLE";

packages/server/src/utils/builders/drop.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import path, { join } from "node:path";
33
import { paths } from "@dokploy/server/constants";
44
import type { Application } from "@dokploy/server/services/application";
55
import { findServerById } from "@dokploy/server/services/server";
6+
import { readValidDirectory } from "@dokploy/server/wss/utils";
67
import AdmZip from "adm-zip";
78
import { Client, type SFTPWrapper } from "ssh2";
89
import {
@@ -62,6 +63,17 @@ export const unzipDrop = async (zipFile: File, application: Application) => {
6263
if (!filePath) continue;
6364

6465
const fullPath = path.join(outputPath, filePath).replace(/\\/g, "/");
66+
if (!readValidDirectory(fullPath, application.serverId)) {
67+
throw new Error(
68+
`Path traversal detected: resolved path escapes output directory: ${filePath}`,
69+
);
70+
}
71+
72+
if (isDangerousNode(entry)) {
73+
throw new Error(
74+
`Dangerous node entries are not allowed: ${entry.entryName}`,
75+
);
76+
}
6577

6678
if (application.serverId) {
6779
if (!entry.isDirectory) {
@@ -132,3 +144,14 @@ const uploadFileToServer = (
132144
});
133145
});
134146
};
147+
148+
function isDangerousNode(entry: AdmZip.IZipEntry) {
149+
const type = (entry.header.attr >> 16) & 0o170000;
150+
151+
return (
152+
type === 0o120000 || // symlink
153+
type === 0o060000 || // block device
154+
type === 0o020000 || // char device
155+
type === 0o010000 // fifo/pipe
156+
);
157+
}

packages/server/src/wss/utils.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import os from "node:os";
2+
import path from "node:path";
3+
import { paths } from "@dokploy/server/constants";
24
import { publicIpv4, publicIpv6 } from "public-ip";
35

46
export const getShell = () => {
@@ -33,3 +35,18 @@ export const getPublicIpWithFallback = async () => {
3335
}
3436
return ip;
3537
};
38+
39+
export const readValidDirectory = (
40+
directory: string,
41+
serverId?: string | null,
42+
) => {
43+
const { BASE_PATH } = paths(!!serverId);
44+
45+
const resolvedBase = path.resolve(BASE_PATH);
46+
const resolvedDir = path.resolve(directory);
47+
48+
return (
49+
resolvedDir === resolvedBase ||
50+
resolvedDir.startsWith(resolvedBase + path.sep)
51+
);
52+
};

0 commit comments

Comments
 (0)