CVE-2026-45661
Description
Dokploy is a free, self-hostable Platform as a Service (PaaS). In 0.26.5 and earlier, a critical path traversal vulnerability exists in Dokploy v0.26.5 that allows authenticated users to write arbitrary files to the filesystem during application deployment. When combined with Dokploy's remote server deployment feature, this vulnerability enables arbitrary file write to remote server filesystems, automatic remote code execution via cron jobs, complete server compromise, data exfiltration without user interaction, and persistent backdoor installation. This vulnerability bypasses all container isolation on remote server deployments.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Authenticated users can exploit a path traversal in Dokploy 0.26.5 and earlier to write arbitrary files, leading to RCE on remote servers.
Vulnerability
Dokploy 0.26.5 and earlier contains a critical path traversal vulnerability in /packages/server/src/utils/builders/drop.ts [1]. The application uses the adm-zip library (v0.5.16) to extract user-uploaded ZIP files during deployment. The code does not validate entry.entryName for path traversal sequences, relying on path.join() which normalizes but does not prevent traversal [1]. As a result, an authenticated user can craft a ZIP archive with entries containing ../ sequences and upload it as part of a deployment. On remote server deployments, the extracted files are written directly to the target server's filesystem via SFTP with no sandboxing, bypassing all container isolation [1].
Exploitation
An attacker needs a valid authenticated account on a Dokploy instance (any user account, no special privileges required) [1]. The attack is conducted remotely over the network and requires no user interaction. The attacker creates a ZIP archive where one or more filenames include path traversal sequences (e.g., ../../etc/cron.d/malicious). The archive is uploaded via the application deployment functionality. Dokploy extracts the archive and, on remote server deployments, writes the files to the intended output path joined with the traversal sequence, resulting in the file being placed at an arbitrary location on the remote server's filesystem [1]. The attack can be automated with no timing race window needed.
Impact
Successful exploitation allows arbitrary file write to the remote server's filesystem. This enables automatic remote code execution by writing a cron job or other scheduled task, leading to complete server compromise, data exfiltration without user interaction, and persistent backdoor installation [1]. The vulnerability bypasses all container isolation on remote server deployments, giving the attacker the full privileges of the Dokploy service account on the remote host [1].
Mitigation
As of the available references [1], no fixed version has been released. Users of Dokploy 0.26.5 and earlier are advised to restrict access to authenticated accounts to only trusted users, monitor deployment logs for suspicious ZIP archives, and consider disabling the remote server deployment feature if not strictly necessary. The vendor has not announced a patch release date. No workaround is provided in the advisory. This vulnerability is not currently listed in CISA's Known Exploited Vulnerabilities (KEV) catalog.
AI Insight generated on May 29, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2Patches
115967f48c6b4afeat(wss): add directory validation for WebSocket server log paths
2 files changed · +17 −0
apps/dokploy/server/wss/listen-deployment.ts+6 −0 modified@@ -3,6 +3,7 @@ import type http from "node:http"; import { findServerById, IS_CLOUD, validateRequest } from "@dokploy/server"; import { Client } from "ssh2"; import { WebSocketServer } from "ws"; +import { readValidDirectory } from "./utils"; export const setupDeploymentLogsWebSocketServer = ( server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>, @@ -40,6 +41,11 @@ export const setupDeploymentLogsWebSocketServer = ( return; } + if (!readValidDirectory(logPath)) { + ws.close(4000, "Invalid log path"); + return; + } + if (!user || !session) { ws.close(); return;
apps/dokploy/server/wss/utils.ts+11 −0 modified@@ -32,6 +32,17 @@ export const isValidShell = (shell: string): boolean => { return allowedShells.includes(shell); }; +export const readValidDirectory = (directory: string) => { + const { BASE_PATH } = paths(); + + const resolvedBase = path.resolve(BASE_PATH); + const resolvedDir = path.resolve(directory); + + return ( + resolvedDir === resolvedBase || + resolvedDir.startsWith(resolvedBase + path.sep) + ); +}; export const getShell = () => { if (IS_CLOUD) { return "NO_AVAILABLE";
24c1c2a37702fix(wss): add container ID validation to enhance security in WebSocket server
1 file changed · +7 −1
apps/dokploy/server/wss/docker-container-logs.ts+7 −1 modified@@ -3,7 +3,7 @@ import { findServerById, IS_CLOUD, validateRequest } from "@dokploy/server"; import { spawn } from "node-pty"; import { Client } from "ssh2"; import { WebSocketServer } from "ws"; -import { getShell } from "./utils"; +import { getShell, isValidContainerId } from "./utils"; export const setupDockerContainerLogsWebSocketServer = ( server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>, @@ -42,6 +42,12 @@ export const setupDockerContainerLogsWebSocketServer = ( return; } + // Security: Validate containerId to prevent command injection + if (!isValidContainerId(containerId)) { + ws.close(4000, "Invalid container ID format"); + return; + } + if (!user || !session) { ws.close(); return;
15e90e9ca9c0refactor(wss): simplify container ID validation and update Docker command structure
1 file changed · +3 −9
apps/dokploy/server/wss/docker-container-terminal.ts+3 −9 modified@@ -3,7 +3,7 @@ import { findServerById, IS_CLOUD, validateRequest } from "@dokploy/server"; import { spawn } from "node-pty"; import { Client } from "ssh2"; import { WebSocketServer } from "ws"; -import { getShell, isValidContainerId, isValidShell } from "./utils"; +import { isValidContainerId, isValidShell } from "./utils"; export const setupDockerContainerTerminalWebSocketServer = ( server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>, @@ -34,11 +34,6 @@ export const setupDockerContainerTerminalWebSocketServer = ( const serverId = url.searchParams.get("serverId"); const { user, session } = await validateRequest(req); - if (!containerId) { - ws.close(4000, "containerId no provided"); - return; - } - if (!containerId) { ws.close(4000, "containerId not provided"); return; @@ -150,10 +145,9 @@ export const setupDockerContainerTerminalWebSocketServer = ( ws.close(); return; } - const shell = getShell(); const ptyProcess = spawn( - shell, - ["-c", `docker exec -it -w / ${containerId} ${activeWay}`], + "docker", + ["exec", "-it", "-w", "/", containerId, shell], {}, );
d1553e1bdaa4fix(wss): add cloud version restriction message in command execution
1 file changed · +8 −1
packages/server/src/utils/schedules/utils.ts+8 −1 modified@@ -1,6 +1,6 @@ import { createWriteStream } from "node:fs"; import path from "node:path"; -import { paths } from "@dokploy/server/constants"; +import { IS_CLOUD, paths } from "@dokploy/server/constants"; import type { Schedule } from "@dokploy/server/db/schema/schedule"; import { createDeploymentSchedule, @@ -93,6 +93,13 @@ export const runCommand = async (scheduleId: string) => { const writeStream = createWriteStream(deployment.logPath, { flags: "a" }); try { + if (IS_CLOUD) { + writeStream.write( + "This feature is not available in the cloud version.", + ); + writeStream.end(); + return; + } writeStream.write( `docker exec ${containerId} ${shellType} -c ${command}\n`, );
880a377e5480fix(wss): handle cloud version restriction in terminal setup
2 files changed · +7 −2
apps/dokploy/server/wss/terminal.ts+6 −1 modified@@ -97,7 +97,12 @@ export const setupTerminalWebSocketServer = ( const isLocalServer = serverId === "local"; - if (isLocalServer && !IS_CLOUD) { + if (isLocalServer) { + if (IS_CLOUD) { + ws.send("This feature is not available in the cloud version."); + ws.close(); + return; + } const port = Number(url.searchParams.get("port")); const username = url.searchParams.get("username");
apps/dokploy/server/wss/utils.ts+1 −1 modified@@ -34,7 +34,7 @@ export const isValidShell = (shell: string): boolean => { export const getShell = () => { if (IS_CLOUD) { - return "CLOUD_VERSION"; + return "NO_AVAILABLE"; } switch (os.platform()) { case "win32":
74e0bd5fe3effix(wss): update Docker command execution in terminal setup
1 file changed · +2 −2
apps/dokploy/server/wss/docker-container-terminal.ts+2 −2 modified@@ -152,8 +152,8 @@ export const setupDockerContainerTerminalWebSocketServer = ( } const shell = getShell(); const ptyProcess = spawn( - "docker", - ["exec", "-it", "-w", "/", containerId, shell], + shell, + ["-c", `docker exec -it -w / ${containerId} ${activeWay}`], {}, );
7362cc49d2b7fix: prevent to pass invalid docker container names
5 files changed · +132 −52
apps/dokploy/server/wss/docker-container-logs.ts+6 −1 modified@@ -1,5 +1,5 @@ import type http from "node:http"; -import { findServerById, validateRequest } from "@dokploy/server"; +import { findServerById, IS_CLOUD, validateRequest } from "@dokploy/server"; import { spawn } from "node-pty"; import { Client } from "ssh2"; import { WebSocketServer } from "ws"; @@ -111,6 +111,11 @@ export const setupDockerContainerLogsWebSocketServer = ( client.end(); }); } else { + if (IS_CLOUD) { + ws.send("This feature is not available in the cloud version."); + ws.close(); + return; + } const shell = getShell(); const baseCommand = `docker ${runType === "swarm" ? "service" : "container"} logs --timestamps ${ runType === "swarm" ? "--raw" : ""
apps/dokploy/server/wss/docker-container-terminal.ts+80 −49 modified@@ -1,9 +1,9 @@ import type http from "node:http"; -import { findServerById, validateRequest } from "@dokploy/server"; +import { findServerById, IS_CLOUD, validateRequest } from "@dokploy/server"; import { spawn } from "node-pty"; import { Client } from "ssh2"; import { WebSocketServer } from "ws"; -import { getShell } from "./utils"; +import { getShell, isValidContainerId, isValidShell } from "./utils"; export const setupDockerContainerTerminalWebSocketServer = ( server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>, @@ -39,6 +39,26 @@ export const setupDockerContainerTerminalWebSocketServer = ( return; } + if (!containerId) { + ws.close(4000, "containerId not provided"); + return; + } + + // Security: Validate containerId to prevent command injection + if (!isValidContainerId(containerId)) { + ws.close(4000, "Invalid container ID format"); + return; + } + + // Security: Validate shell to prevent command injection + if (activeWay && !isValidShell(activeWay)) { + ws.close(4000, "Invalid shell specified"); + return; + } + + // Default to 'sh' if no shell specified + const shell = activeWay || "sh"; + if (!user || !session) { ws.close(); return; @@ -54,55 +74,61 @@ export const setupDockerContainerTerminalWebSocketServer = ( let _stderr = ""; conn .once("ready", () => { - conn.exec( - `docker exec -it -w / ${containerId} ${activeWay}`, - { pty: true }, - (err, stream) => { - if (err) { - console.error("SSH exec error:", err); - ws.close(); + // Use array-style arguments to prevent shell injection + const dockerCommand = [ + "docker", + "exec", + "-it", + "-w", + "/", + containerId, + shell, + ].join(" "); + conn.exec(dockerCommand, { pty: true }, (err, stream) => { + if (err) { + console.error("SSH exec error:", err); + ws.close(); + conn.end(); + return; + } + + stream + .on("close", (code: number, _signal: string) => { + ws.send(`\nContainer closed with code: ${code}\n`); conn.end(); - return; - } + }) + .on("data", (data: string) => { + _stdout += data.toString(); + ws.send(data.toString()); + }) + .stderr.on("data", (data) => { + _stderr += data.toString(); + ws.send(data.toString()); + console.error("Error: ", data.toString()); + }); - stream - .on("close", (code: number, _signal: string) => { - ws.send(`\nContainer closed with code: ${code}\n`); - conn.end(); - }) - .on("data", (data: string) => { - _stdout += data.toString(); - ws.send(data.toString()); - }) - .stderr.on("data", (data) => { - _stderr += data.toString(); - ws.send(data.toString()); - console.error("Error: ", data.toString()); - }); - - ws.on("message", (message) => { - try { - let command: string | Buffer[] | Buffer | ArrayBuffer; - if (Buffer.isBuffer(message)) { - command = message.toString("utf8"); - } else { - command = message; - } - stream.write(command.toString()); - } catch (error) { - // @ts-ignore - const errorMessage = error?.message as unknown as string; - ws.send(errorMessage); + ws.on("message", (message) => { + try { + let command: string | Buffer[] | Buffer | ArrayBuffer; + if (Buffer.isBuffer(message)) { + command = message.toString("utf8"); + } else { + command = message; } - }); + stream.write(command.toString()); + } catch (error) { + // @ts-ignore + const errorMessage = error?.message as unknown as string; + ws.send(errorMessage); + } + }); - ws.on("close", () => { - stream.end(); - // Ensure SSH connection is closed when WebSocket closes - conn.end(); - }); - }, - ); + ws.on("close", () => { + stream.end(); + // Ensure SSH connection is closed when WebSocket closes + conn.end(); + }); + }); }) .on("error", (err) => { console.error("SSH connection error:", err); @@ -119,10 +145,15 @@ export const setupDockerContainerTerminalWebSocketServer = ( privateKey: server.sshKey?.privateKey, }); } else { + if (IS_CLOUD) { + ws.send("This feature is not available in the cloud version."); + ws.close(); + return; + } const shell = getShell(); const ptyProcess = spawn( - shell, - ["-c", `docker exec -it -w / ${containerId} ${activeWay}`], + "docker", + ["exec", "-it", "-w", "/", containerId, shell], {}, );
apps/dokploy/server/wss/docker-stats.ts+7 −0 modified@@ -4,6 +4,7 @@ import { execAsync, getHostSystemStats, getLastAdvancedStatsFile, + IS_CLOUD, recordAdvancedStats, validateRequest, } from "@dokploy/server"; @@ -32,6 +33,12 @@ export const setupDockerStatsMonitoringSocketServer = ( wssTerm.on("connection", async (ws, req) => { const url = new URL(req.url || "", `http://${req.headers.host}`); + + if (IS_CLOUD) { + ws.send("This feature is not available in the cloud version."); + ws.close(); + return; + } const appName = url.searchParams.get("appName"); const appType = (url.searchParams.get("appType") || "application") as | "application"
apps/dokploy/server/wss/listen-deployment.ts+6 −1 modified@@ -1,6 +1,6 @@ import { spawn } from "node:child_process"; import type http from "node:http"; -import { findServerById, validateRequest } from "@dokploy/server"; +import { findServerById, IS_CLOUD, validateRequest } from "@dokploy/server"; import { Client } from "ssh2"; import { WebSocketServer } from "ws"; @@ -108,6 +108,11 @@ export const setupDeploymentLogsWebSocketServer = ( } }); } else { + if (IS_CLOUD) { + ws.send("This feature is not available in the cloud version."); + ws.close(); + return; + } tailProcess = spawn("tail", ["-n", "+1", "-f", logPath]); const stdout = tailProcess.stdout;
apps/dokploy/server/wss/utils.ts+33 −1 modified@@ -1,9 +1,41 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { execAsync, paths } from "@dokploy/server"; +import { execAsync, IS_CLOUD, paths } from "@dokploy/server"; + +/** + * Validates that the container ID matches Docker's expected format. + * Docker container IDs are 64-character hex strings (or 12-char short form). + * Also allows container names: alphanumeric, underscores, hyphens, and dots. + */ +export const isValidContainerId = (id: string): boolean => { + // Match full ID (64 hex chars), short ID (12 hex chars), or container name + const hexPattern = /^[a-f0-9]{12,64}$/i; + const namePattern = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/; + return hexPattern.test(id) || (namePattern.test(id) && id.length <= 128); +}; + +/** + * Validates that the shell is one of the allowed shells. + */ +export const isValidShell = (shell: string): boolean => { + const allowedShells = [ + "sh", + "bash", + "zsh", + "ash", + "/bin/sh", + "/bin/bash", + "/bin/zsh", + "/bin/ash", + ]; + return allowedShells.includes(shell); +}; export const getShell = () => { + if (IS_CLOUD) { + return "CLOUD_VERSION"; + } switch (os.platform()) { case "win32": return "powershell.exe";
733f4c4a23adfix(db): update security migration command for database configuration
1 file changed · +1 −1
packages/server/src/db/constants.ts+1 −1 modified@@ -32,7 +32,7 @@ if (DATABASE_URL) { This mode WILL BE REMOVED in a future release. Please migrate to Docker Secrets using POSTGRES_PASSWORD_FILE. - Please execute this guide: https://dokploy.com/SECURITY_MIGRATION.md + Please execute this command in your server: curl -sSL https://dokploy.com/security/0.26.6.sh | bash `); dbUrl = "postgres://dokploy:amukds4wi9001583845717ad2@dokploy-postgres:5432/dokploy";
c1d452bcf772Complete fix for Stack compose environment variable substitution
2 files changed · +2 −6
apps/dokploy/components/dashboard/application/preview-deployments/show-preview-deployments.tsx+1 −1 modified@@ -1,3 +1,4 @@ +import * as TooltipPrimitive from "@radix-ui/react-tooltip"; import { ExternalLink, FileText, @@ -29,7 +30,6 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; -import * as TooltipPrimitive from "@radix-ui/react-tooltip"; import { api } from "@/utils/api"; import { ShowModalLogs } from "../../settings/web-server/show-modal-logs"; import { ShowDeploymentsModal } from "../deployments/show-deployments-modal";
apps/dokploy/__test__/env/stack-environment.test.ts+1 −5 modified@@ -72,11 +72,7 @@ PASSWORD=secret123 DATABASE_URL=postgresql://\${{environment.USERNAME}}:\${{environment.PASSWORD}}@\${{environment.HOST}}:\${{environment.PORT}}/mydb `; - const result = getEnviromentVariablesObject( - serviceEnv, - "", - multiRefEnv, - ); + const result = getEnviromentVariablesObject(serviceEnv, "", multiRefEnv); expect(result).toEqual({ DATABASE_URL: "postgresql://postgres:secret123@localhost:5432/mydb",
39b4b2d9a0b9d0ea8b528375Merge pull request #3504 from Bima42/fix/3503-changing-server-domain-fail-with-only-mail
1 file changed · +4 −1
packages/server/src/db/schema/web-server-settings.ts+4 −1 modified@@ -131,7 +131,10 @@ export const apiAssignDomain = z .object({ host: z.string(), certificateType: z.enum(["letsencrypt", "none", "custom"]), - letsEncryptEmail: z.string().email().optional().nullable(), + letsEncryptEmail: z + .union([z.string().email(), z.literal("")]) + .optional() + .nullable(), https: z.boolean().optional(), }) .required()
Vulnerability mechanics
Root cause
"Missing validation of ZIP entry filenames for path-traversal sequences before writing to the filesystem."
Attack vector
An authenticated attacker with deployment permissions uploads a crafted ZIP archive containing entries with path-traversal sequences (e.g., `../../../../../etc/cron.d/malicious-cron`). The application extracts the archive without sanitizing entry names, writing files to arbitrary locations on the filesystem. When the target is a remote server deployed via SFTP, the file write occurs directly on the remote host, allowing the attacker to plant a cron job that executes immediately and exfiltrates sensitive data. [CWE-22] [ref_id=1]
Affected code
The vulnerability resides in `/packages/server/src/utils/builders/drop.ts` (lines 52–86). The code extracts user-uploaded ZIP files using the `adm-zip` library but does **not** validate `entry.entryName` for path-traversal sequences before joining it with the output path and writing to disk. On remote server deployments, the same unsanitized path is passed to `uploadFileToServer()` via SFTP, bypassing any container isolation. [ref_id=1]
What the fix does
The provided patch (`patch_id=3104638`) only adjusts a Zod validation schema for `letsEncryptEmail` and does **not** address the path traversal in `drop.ts`. The advisory recommends adding path sanitization (removing `../` sequences, normalizing the path, and verifying it stays within the intended output directory) and rejecting dangerous patterns such as `/etc/`, `cron`, `.ssh`, or `systemd`. No fix for the traversal itself is present in the supplied patch. [ref_id=1]
Preconditions
- authValid user account with deployment permissions on a Dokploy instance
- configTarget application configured with the 'Drop' deployment provider
- networkNetwork access to the Dokploy web interface
- inputAttacker-controlled ZIP file containing path-traversal entry names
Generated on May 29, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
1News mentions
0No linked articles in our index yet.