From 7362cc49d2b7b57630a70756063cc5ecc2dbf3fc Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Mon, 26 Jan 2026 16:37:15 +0200 Subject: [PATCH] fix: prevent to pass invalid docker container names --- .../server/wss/docker-container-logs.ts | 7 +- .../server/wss/docker-container-terminal.ts | 129 +++++++++++------- apps/dokploy/server/wss/docker-stats.ts | 7 + apps/dokploy/server/wss/listen-deployment.ts | 7 +- apps/dokploy/server/wss/utils.ts | 34 ++++- 5 files changed, 132 insertions(+), 52 deletions(-) diff --git a/apps/dokploy/server/wss/docker-container-logs.ts b/apps/dokploy/server/wss/docker-container-logs.ts index eaefa21f1..77fba8bb5 100644 --- a/apps/dokploy/server/wss/docker-container-logs.ts +++ b/apps/dokploy/server/wss/docker-container-logs.ts @@ -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" : "" diff --git a/apps/dokploy/server/wss/docker-container-terminal.ts b/apps/dokploy/server/wss/docker-container-terminal.ts index 155d7f0cc..2bdaaf73d 100644 --- a/apps/dokploy/server/wss/docker-container-terminal.ts +++ b/apps/dokploy/server/wss/docker-container-terminal.ts @@ -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, @@ -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], {}, ); diff --git a/apps/dokploy/server/wss/docker-stats.ts b/apps/dokploy/server/wss/docker-stats.ts index bd740e976..b5f2439bf 100644 --- a/apps/dokploy/server/wss/docker-stats.ts +++ b/apps/dokploy/server/wss/docker-stats.ts @@ -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" diff --git a/apps/dokploy/server/wss/listen-deployment.ts b/apps/dokploy/server/wss/listen-deployment.ts index ca49cea29..75ddf7d1d 100644 --- a/apps/dokploy/server/wss/listen-deployment.ts +++ b/apps/dokploy/server/wss/listen-deployment.ts @@ -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; diff --git a/apps/dokploy/server/wss/utils.ts b/apps/dokploy/server/wss/utils.ts index 1a65fc520..7ec0f9ce8 100644 --- a/apps/dokploy/server/wss/utils.ts +++ b/apps/dokploy/server/wss/utils.ts @@ -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";