From 43bfe0223575ea4cfed375a123e2a5ceadcafd8c Mon Sep 17 00:00:00 2001 From: Luke Hagar Date: Mon, 29 Dec 2025 13:42:57 -0600 Subject: [PATCH 1/2] refactor(docker): enhance container ID validation and shell command safety - Introduced a Zod schema for validating Docker container IDs in the docker router. - Updated WebSocket server to validate container IDs and shell types before execution. - Improved shell command safety by escaping container IDs and shell types in various services. - Added log path validation to prevent path traversal vulnerabilities in deployment and mount services. --- apps/dokploy/server/api/routers/docker.ts | 23 ++-- .../server/wss/docker-container-terminal.ts | 29 ++++- packages/server/src/lib/auth.ts | 14 +- packages/server/src/services/deployment.ts | 46 +++++-- packages/server/src/services/mount.ts | 28 +++- packages/server/src/services/registry.ts | 7 +- .../server/src/utils/process/execAsync.ts | 5 +- packages/server/src/utils/schedules/utils.ts | 52 ++++++-- .../src/utils/security/path-validation.ts | 120 ++++++++++++++++++ .../server/src/utils/security/shell-escape.ts | 61 +++++++++ 10 files changed, 344 insertions(+), 41 deletions(-) create mode 100644 packages/server/src/utils/security/path-validation.ts create mode 100644 packages/server/src/utils/security/shell-escape.ts diff --git a/apps/dokploy/server/api/routers/docker.ts b/apps/dokploy/server/api/routers/docker.ts index cc75f4852b..b4196e1327 100644 --- a/apps/dokploy/server/api/routers/docker.ts +++ b/apps/dokploy/server/api/routers/docker.ts @@ -8,12 +8,25 @@ import { getServiceContainersByAppName, getStackContainersByAppName, } from "@dokploy/server"; +import { validateContainerId } from "@dokploy/server/utils/security/shell-escape"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "../trpc"; +// Legacy regex for app names (not container IDs) export const containerIdRegex = /^[a-zA-Z0-9.\-_]+$/; +// Zod schema for validating Docker container IDs +const containerIdSchema = z + .string() + .min(1) + .refine( + (id) => validateContainerId(id), + { + message: "Invalid container ID format. Must be 12-64 hexadecimal characters.", + }, + ); + export const dockerRouter = createTRPCRouter({ getContainers: protectedProcedure .input( @@ -34,10 +47,7 @@ export const dockerRouter = createTRPCRouter({ restartContainer: protectedProcedure .input( z.object({ - containerId: z - .string() - .min(1) - .regex(containerIdRegex, "Invalid container id."), + containerId: containerIdSchema, }), ) .mutation(async ({ input }) => { @@ -47,10 +57,7 @@ export const dockerRouter = createTRPCRouter({ getConfig: protectedProcedure .input( z.object({ - containerId: z - .string() - .min(1) - .regex(containerIdRegex, "Invalid container id."), + containerId: containerIdSchema, serverId: z.string().optional(), }), ) diff --git a/apps/dokploy/server/wss/docker-container-terminal.ts b/apps/dokploy/server/wss/docker-container-terminal.ts index 155d7f0cc5..b0ffa684f0 100644 --- a/apps/dokploy/server/wss/docker-container-terminal.ts +++ b/apps/dokploy/server/wss/docker-container-terminal.ts @@ -1,5 +1,10 @@ import type http from "node:http"; import { findServerById, validateRequest } from "@dokploy/server"; +import { + shEscape, + validateContainerId, + validateShellType, +} from "@dokploy/server/utils/security/shell-escape"; import { spawn } from "node-pty"; import { Client } from "ssh2"; import { WebSocketServer } from "ws"; @@ -30,7 +35,7 @@ export const setupDockerContainerTerminalWebSocketServer = ( wssTerm.on("connection", async (ws, req) => { const url = new URL(req.url || "", `http://${req.headers.host}`); const containerId = url.searchParams.get("containerId"); - const activeWay = url.searchParams.get("activeWay"); + const activeWay = url.searchParams.get("activeWay") || "sh"; const serverId = url.searchParams.get("serverId"); const { user, session } = await validateRequest(req); @@ -39,6 +44,17 @@ export const setupDockerContainerTerminalWebSocketServer = ( return; } + // Validate container ID and shell type + if (!validateContainerId(containerId)) { + ws.close(4001, "Invalid container ID format"); + return; + } + + if (!validateShellType(activeWay)) { + ws.close(4002, "Invalid shell type"); + return; + } + if (!user || !session) { ws.close(); return; @@ -52,10 +68,15 @@ export const setupDockerContainerTerminalWebSocketServer = ( const conn = new Client(); let _stdout = ""; let _stderr = ""; + + // Escape containerId and activeWay for safe shell execution + const escapedContainerId = shEscape(containerId); + const escapedActiveWay = shEscape(activeWay); + conn .once("ready", () => { conn.exec( - `docker exec -it -w / ${containerId} ${activeWay}`, + `docker exec -it -w / ${escapedContainerId} ${escapedActiveWay}`, { pty: true }, (err, stream) => { if (err) { @@ -120,9 +141,11 @@ export const setupDockerContainerTerminalWebSocketServer = ( }); } else { const shell = getShell(); + // Use array arguments instead of shell string to prevent injection + // docker exec -it -w / const ptyProcess = spawn( shell, - ["-c", `docker exec -it -w / ${containerId} ${activeWay}`], + ["-c", `docker exec -it -w / ${shEscape(containerId)} ${shEscape(activeWay)}`], {}, ); diff --git a/packages/server/src/lib/auth.ts b/packages/server/src/lib/auth.ts index d4379e4bef..5af48016d4 100644 --- a/packages/server/src/lib/auth.ts +++ b/packages/server/src/lib/auth.ts @@ -303,9 +303,17 @@ export const validateRequest = async (request: IncomingMessage) => { }; } - const organizationId = JSON.parse( - apiKeyRecord.metadata || "{}", - ).organizationId; + let organizationId: string | undefined; + try { + const metadata = JSON.parse(apiKeyRecord.metadata || "{}"); + organizationId = metadata?.organizationId; + } catch (error) { + console.error("Error parsing API key metadata", error); + return { + session: null, + user: null, + }; + } if (!organizationId) { return { diff --git a/packages/server/src/services/deployment.ts b/packages/server/src/services/deployment.ts index 6244ec8eb6..a86d82e273 100644 --- a/packages/server/src/services/deployment.ts +++ b/packages/server/src/services/deployment.ts @@ -14,6 +14,8 @@ import { } from "@dokploy/server/db/schema"; import { removeDirectoryIfExistsContent } from "@dokploy/server/utils/filesystem/directory"; import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; +import { shEscape } from "@dokploy/server/utils/security/shell-escape"; +import { validateLogPath } from "@dokploy/server/utils/security/path-validation"; import { TRPCError } from "@trpc/server"; import { format } from "date-fns"; import { desc, eq } from "drizzle-orm"; @@ -627,31 +629,55 @@ const removeLastTenDeployments = async ( let command = ""; for (const oldDeployment of deploymentsToDelete) { const logPath = path.join(oldDeployment.logPath); - if (oldDeployment.rollbackId) { - await removeRollbackById(oldDeployment.rollbackId); - } - - if (logPath !== ".") { + + // Validate log path before deletion to prevent path traversal + if (logPath !== "." && validateLogPath(logPath, serverId)) { + if (oldDeployment.rollbackId) { + await removeRollbackById(oldDeployment.rollbackId); + } + // Escape logPath for safe shell execution + const escapedLogPath = shEscape(logPath); command += ` - rm -rf ${logPath}; + rm -rf ${escapedLogPath}; `; + await removeDeployment(oldDeployment.deploymentId); + } else if (logPath === ".") { + // Handle rollback removal even if logPath is invalid + if (oldDeployment.rollbackId) { + await removeRollbackById(oldDeployment.rollbackId); + } + await removeDeployment(oldDeployment.deploymentId); + } else { + // Invalid path - skip deletion but still remove from DB + console.warn(`Skipping deletion of invalid log path: ${logPath}`); + if (oldDeployment.rollbackId) { + await removeRollbackById(oldDeployment.rollbackId); + } + await removeDeployment(oldDeployment.deploymentId); } - await removeDeployment(oldDeployment.deploymentId); } - await execAsyncRemote(serverId, command); + if (command.trim()) { + await execAsyncRemote(serverId, command); + } } else { for (const oldDeployment of deploymentsToDelete) { if (oldDeployment.rollbackId) { await removeRollbackById(oldDeployment.rollbackId); } const logPath = path.join(oldDeployment.logPath); + + // Validate log path before deletion to prevent path traversal if ( + logPath !== "." && + validateLogPath(logPath, null) && existsSync(logPath) && - !oldDeployment.errorMessage && - logPath !== "." + !oldDeployment.errorMessage ) { await fsPromises.unlink(logPath); + } else if (logPath !== "." && !validateLogPath(logPath, null)) { + // Invalid path - log warning but continue + console.warn(`Skipping deletion of invalid log path: ${logPath}`); } await removeDeployment(oldDeployment.deploymentId); } diff --git a/packages/server/src/services/mount.ts b/packages/server/src/services/mount.ts index f08a32312e..bd850365c5 100644 --- a/packages/server/src/services/mount.ts +++ b/packages/server/src/services/mount.ts @@ -16,6 +16,8 @@ import { execAsync, execAsyncRemote, } from "@dokploy/server/utils/process/execAsync"; +import { shEscape } from "@dokploy/server/utils/security/shell-escape"; +import { validateFilePath } from "@dokploy/server/utils/security/path-validation"; import { TRPCError } from "@trpc/server"; import { eq, type SQL, sql } from "drizzle-orm"; @@ -292,10 +294,21 @@ export const updateFileMount = async (mountId: string) => { const basePath = await getBaseFilesPath(mountId); const fullPath = path.join(basePath, mount.filePath); + // Validate path is within allowed directory + if (!validateFilePath(mount.filePath, basePath)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Invalid file path: path traversal detected", + }); + } + try { const serverId = await getServerId(mount); const encodedContent = encodeBase64(mount.content || ""); - const command = `echo "${encodedContent}" | base64 -d > ${fullPath}`; + // Escape fullPath for safe shell execution + const escapedFullPath = shEscape(fullPath); + const escapedEncodedContent = shEscape(encodedContent); + const command = `echo ${escapedEncodedContent} | base64 -d > ${escapedFullPath}`; if (serverId) { await execAsyncRemote(serverId, command); } else { @@ -312,10 +325,21 @@ export const deleteFileMount = async (mountId: string) => { const basePath = await getBaseFilesPath(mountId); const fullPath = path.join(basePath, mount.filePath); + + // Validate path is within allowed directory + if (!validateFilePath(mount.filePath, basePath)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Invalid file path: path traversal detected", + }); + } + try { const serverId = await getServerId(mount); if (serverId) { - const command = `rm -rf ${fullPath}`; + // Escape fullPath for safe shell execution + const escapedFullPath = shEscape(fullPath); + const command = `rm -rf ${escapedFullPath}`; await execAsyncRemote(serverId, command); } else { await removeFileOrDirectory(fullPath); diff --git a/packages/server/src/services/registry.ts b/packages/server/src/services/registry.ts index ec8db8fa84..55177281f6 100644 --- a/packages/server/src/services/registry.ts +++ b/packages/server/src/services/registry.ts @@ -4,16 +4,15 @@ import { execAsync, execAsyncRemote, } from "@dokploy/server/utils/process/execAsync"; +import { shEscape } from "@dokploy/server/utils/security/shell-escape"; import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; import { IS_CLOUD } from "../constants"; export type Registry = typeof registry.$inferSelect; -function shEscape(s: string | undefined): string { - if (!s) return "''"; - return `'${s.replace(/'/g, `'\\''`)}'`; -} +// Re-export shEscape for backward compatibility +export { shEscape }; function safeDockerLoginCommand( registry: string | undefined, diff --git a/packages/server/src/utils/process/execAsync.ts b/packages/server/src/utils/process/execAsync.ts index cd0249000e..0ec0cbf1ea 100644 --- a/packages/server/src/utils/process/execAsync.ts +++ b/packages/server/src/utils/process/execAsync.ts @@ -150,10 +150,11 @@ export const execAsyncRemote = async ( let stdout = ""; let stderr = ""; - return new Promise((resolve, reject) => { + return new Promise(async (resolve, reject) => { const conn = new Client(); - sleep(1000); + // Add delay before connecting to avoid race conditions + await sleep(1000); conn .once("ready", () => { conn.exec(command, (err, stream) => { diff --git a/packages/server/src/utils/schedules/utils.ts b/packages/server/src/utils/schedules/utils.ts index 1a43d2af6b..01e46d4768 100644 --- a/packages/server/src/utils/schedules/utils.ts +++ b/packages/server/src/utils/schedules/utils.ts @@ -8,6 +8,12 @@ import { updateDeploymentStatus, } from "@dokploy/server/services/deployment"; import { findScheduleById } from "@dokploy/server/services/schedule"; +import { + shEscape, + validateContainerId, + validateShellType, +} from "@dokploy/server/utils/security/shell-escape"; +import { validateLogPath } from "@dokploy/server/utils/security/path-validation"; import { scheduledJobs, scheduleJob as scheduleJobNode } from "node-schedule"; import { getComposeContainer, getServiceContainer } from "../docker/utils"; import { execAsyncRemote } from "../process/execAsync"; @@ -71,18 +77,35 @@ export const runCommand = async (scheduleId: string) => { serverId = compose.serverId || ""; } + // Validate inputs + if (!validateContainerId(containerId)) { + throw new Error(`Invalid container ID: ${containerId}`); + } + if (!validateShellType(shellType || "")) { + throw new Error(`Invalid shell type: ${shellType}`); + } + if (!validateLogPath(deployment.logPath, serverId || null)) { + throw new Error(`Invalid log path: ${deployment.logPath}`); + } + if (serverId) { try { + // Escape all variables for safe shell execution + const escapedContainerId = shEscape(containerId); + const escapedShellType = shEscape(shellType); + const escapedCommand = shEscape(command); + const escapedLogPath = shEscape(deployment.logPath); + await execAsyncRemote( serverId, ` set -e - echo "Running command: docker exec ${containerId} ${shellType} -c '${command}'" >> ${deployment.logPath}; - docker exec ${containerId} ${shellType} -c '${command}' >> ${deployment.logPath} 2>> ${deployment.logPath} || { - echo "❌ Command failed" >> ${deployment.logPath}; + echo "Running command: docker exec ${escapedContainerId} ${escapedShellType} -c ${escapedCommand}" >> ${escapedLogPath}; + docker exec ${escapedContainerId} ${escapedShellType} -c ${escapedCommand} >> ${escapedLogPath} 2>> ${escapedLogPath} || { + echo "❌ Command failed" >> ${escapedLogPath}; exit 1; } - echo "✅ Command executed successfully" >> ${deployment.logPath}; + echo "✅ Command executed successfully" >> ${escapedLogPath}; `, ); } catch (error) { @@ -94,8 +117,9 @@ export const runCommand = async (scheduleId: string) => { try { writeStream.write( - `docker exec ${containerId} ${shellType} -c ${command}\n`, + `docker exec ${containerId} ${shellType} -c ${shEscape(command)}\n`, ); + // spawnAsync with array arguments is safe - no shell interpretation await spawnAsync( "docker", ["exec", containerId, shellType, "-c", command], @@ -149,16 +173,26 @@ export const runCommand = async (scheduleId: string) => { } } else if (scheduleType === "server") { try { + // Validate log path + if (!validateLogPath(deployment.logPath, serverId || null)) { + throw new Error(`Invalid log path: ${deployment.logPath}`); + } + const { SCHEDULES_PATH } = paths(true); const fullPath = path.join(SCHEDULES_PATH, appName || ""); + + // Escape paths and log path for safe shell execution + const escapedFullPath = shEscape(fullPath); + const escapedLogPath = shEscape(deployment.logPath); + const command = ` set -e - echo "Running script" >> ${deployment.logPath}; - bash -c ${fullPath}/script.sh 2>&1 | tee -a ${deployment.logPath} || { - echo "❌ Command failed" >> ${deployment.logPath}; + echo "Running script" >> ${escapedLogPath}; + bash -c ${escapedFullPath}/script.sh 2>&1 | tee -a ${escapedLogPath} || { + echo "❌ Command failed" >> ${escapedLogPath}; exit 1; } - echo "✅ Command executed successfully" >> ${deployment.logPath}; + echo "✅ Command executed successfully" >> ${escapedLogPath}; `; await execAsyncRemote(serverId, command, async (data) => { // we need to extract the PID and Schedule ID from the data diff --git a/packages/server/src/utils/security/path-validation.ts b/packages/server/src/utils/security/path-validation.ts new file mode 100644 index 0000000000..cd529c50ac --- /dev/null +++ b/packages/server/src/utils/security/path-validation.ts @@ -0,0 +1,120 @@ +import path from "node:path"; +import { paths } from "../../constants"; + +/** + * Path validation utilities for preventing directory traversal attacks + * Ensures all file operations are restricted to allowed directories + */ + +/** + * Validate that a path is within a base directory + * Prevents directory traversal attacks by ensuring resolved path starts with base + * @param userPath - User-provided path to validate + * @param basePath - Base directory that the path must be within + * @returns true if path is safe, false otherwise + */ +export function validatePath(userPath: string, basePath: string): boolean { + if (!userPath || !basePath) { + return false; + } + + // Prevent directory traversal patterns + if (userPath.includes("../") || userPath.includes("..\\")) { + return false; + } + + // Prevent null bytes + if (userPath.includes("\0") || userPath.includes("\x00")) { + return false; + } + + try { + // Resolve both paths to absolute paths + const resolvedBase = path.resolve(basePath); + const resolvedUser = path.resolve(basePath, userPath); + + // Ensure the resolved user path starts with the resolved base path + // This prevents directory traversal + return resolvedUser.startsWith(resolvedBase + path.sep) || resolvedUser === resolvedBase; + } catch { + // If path resolution fails, it's not safe + return false; + } +} + +/** + * Sanitize a path by removing dangerous characters and normalizing + * @param userPath - Path to sanitize + * @returns Sanitized path + */ +export function sanitizePath(userPath: string): string { + if (!userPath) { + return ""; + } + + // Remove null bytes + let sanitized = userPath.replace(/\0/g, "").replace(/\x00/g, ""); + + // Remove directory traversal patterns + sanitized = sanitized.replace(/\.\.\//g, "").replace(/\.\.\\/g, ""); + + // Normalize path separators + sanitized = sanitized.replace(/\\/g, "/"); + + // Remove leading slashes that could make it absolute + sanitized = sanitized.replace(/^\/+/, ""); + + return sanitized; +} + +/** + * Resolve a path safely with validation + * Throws an error if the path is not within the base directory + * @param userPath - User-provided path + * @param basePath - Base directory + * @returns Resolved absolute path + * @throws Error if path is not safe + */ +export function resolveSafePath(userPath: string, basePath: string): string { + if (!validatePath(userPath, basePath)) { + throw new Error( + `Invalid path: path traversal or unauthorized directory access detected. Path: ${userPath}, Base: ${basePath}`, + ); + } + + try { + return path.resolve(basePath, userPath); + } catch (error) { + throw new Error(`Failed to resolve path: ${error instanceof Error ? error.message : String(error)}`); + } +} + +/** + * Validate log path is within allowed directories + * @param logPath - Log path to validate + * @param serverId - Optional server ID to determine if remote or local + * @returns true if valid, false otherwise + */ +export function validateLogPath(logPath: string, serverId?: string | null): boolean { + if (!logPath || logPath === ".") { + return false; + } + + const { LOGS_PATH } = paths(!!serverId); + return validatePath(logPath, LOGS_PATH); +} + +/** + * Validate file path is within allowed application directories + * @param filePath - File path to validate + * @param basePath - Base path for the application + * @returns true if valid, false otherwise + */ +export function validateFilePath(filePath: string, basePath: string): boolean { + if (!filePath) { + return false; + } + + return validatePath(filePath, basePath); +} + diff --git a/packages/server/src/utils/security/shell-escape.ts b/packages/server/src/utils/security/shell-escape.ts new file mode 100644 index 0000000000..8e41eb0071 --- /dev/null +++ b/packages/server/src/utils/security/shell-escape.ts @@ -0,0 +1,61 @@ +/** + * Shell escaping utilities for safe command construction + * Prevents command injection vulnerabilities by properly escaping shell arguments + */ + +/** + * Escape a string for safe use in shell commands + * Wraps the string in single quotes and escapes any single quotes within + * @param s - String to escape (can be undefined) + * @returns Escaped string safe for shell use + */ +export function shEscape(s: string | undefined): string { + if (!s) return "''"; + return `'${s.replace(/'/g, `'\\''`)}'`; +} + +/** + * Escape a single shell argument + * Alias for shEscape for clarity + */ +export function escapeShellArg(value: string): string { + return shEscape(value); +} + +/** + * Escape an array of shell arguments + * @param args - Array of strings to escape + * @returns Array of escaped strings + */ +export function escapeShellArgs(args: string[]): string[] { + return args.map((arg) => shEscape(arg)); +} + +/** + * Validate Docker container ID format + * Container IDs are either: + * - 64 hexadecimal characters (full ID) + * - 12 hexadecimal characters (short ID) + * - Empty string (for validation purposes) + * @param id - Container ID to validate + * @returns true if valid, false otherwise + */ +export function validateContainerId(id: string): boolean { + if (!id || id.length === 0) { + return false; + } + // Docker container IDs are hexadecimal + const hexPattern = /^[a-f0-9]{12,64}$/i; + return hexPattern.test(id); +} + +/** + * Validate shell type is from allowed whitelist + * @param type - Shell type to validate + * @returns true if valid, false otherwise + */ +export function validateShellType(type: string): boolean { + const allowedShells = ["sh", "bash", "zsh", "ash", "dash", "fish", "csh", "tcsh"]; + return allowedShells.includes(type.toLowerCase()); +} + From 75e8bdc9ec002a4ba20c278f3acf96f1c0fb20ec Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 30 Dec 2025 14:47:09 +0000 Subject: [PATCH 2/2] [autofix.ci] apply automated fixes --- apps/dokploy/server/api/routers/docker.ts | 10 ++++------ .../server/wss/docker-container-terminal.ts | 9 ++++++--- packages/server/src/services/deployment.ts | 4 ++-- packages/server/src/services/mount.ts | 2 +- packages/server/src/utils/schedules/utils.ts | 2 +- .../server/src/utils/security/path-validation.ts | 15 +++++++++++---- .../server/src/utils/security/shell-escape.ts | 12 ++++++++++-- 7 files changed, 35 insertions(+), 19 deletions(-) diff --git a/apps/dokploy/server/api/routers/docker.ts b/apps/dokploy/server/api/routers/docker.ts index b4196e1327..a8cbd723be 100644 --- a/apps/dokploy/server/api/routers/docker.ts +++ b/apps/dokploy/server/api/routers/docker.ts @@ -20,12 +20,10 @@ export const containerIdRegex = /^[a-zA-Z0-9.\-_]+$/; const containerIdSchema = z .string() .min(1) - .refine( - (id) => validateContainerId(id), - { - message: "Invalid container ID format. Must be 12-64 hexadecimal characters.", - }, - ); + .refine((id) => validateContainerId(id), { + message: + "Invalid container ID format. Must be 12-64 hexadecimal characters.", + }); export const dockerRouter = createTRPCRouter({ getContainers: protectedProcedure diff --git a/apps/dokploy/server/wss/docker-container-terminal.ts b/apps/dokploy/server/wss/docker-container-terminal.ts index b0ffa684f0..dbd0cd86fd 100644 --- a/apps/dokploy/server/wss/docker-container-terminal.ts +++ b/apps/dokploy/server/wss/docker-container-terminal.ts @@ -68,11 +68,11 @@ export const setupDockerContainerTerminalWebSocketServer = ( const conn = new Client(); let _stdout = ""; let _stderr = ""; - + // Escape containerId and activeWay for safe shell execution const escapedContainerId = shEscape(containerId); const escapedActiveWay = shEscape(activeWay); - + conn .once("ready", () => { conn.exec( @@ -145,7 +145,10 @@ export const setupDockerContainerTerminalWebSocketServer = ( // docker exec -it -w / const ptyProcess = spawn( shell, - ["-c", `docker exec -it -w / ${shEscape(containerId)} ${shEscape(activeWay)}`], + [ + "-c", + `docker exec -it -w / ${shEscape(containerId)} ${shEscape(activeWay)}`, + ], {}, ); diff --git a/packages/server/src/services/deployment.ts b/packages/server/src/services/deployment.ts index a86d82e273..8af6b65d5e 100644 --- a/packages/server/src/services/deployment.ts +++ b/packages/server/src/services/deployment.ts @@ -629,7 +629,7 @@ const removeLastTenDeployments = async ( let command = ""; for (const oldDeployment of deploymentsToDelete) { const logPath = path.join(oldDeployment.logPath); - + // Validate log path before deletion to prevent path traversal if (logPath !== "." && validateLogPath(logPath, serverId)) { if (oldDeployment.rollbackId) { @@ -666,7 +666,7 @@ const removeLastTenDeployments = async ( await removeRollbackById(oldDeployment.rollbackId); } const logPath = path.join(oldDeployment.logPath); - + // Validate log path before deletion to prevent path traversal if ( logPath !== "." && diff --git a/packages/server/src/services/mount.ts b/packages/server/src/services/mount.ts index bd850365c5..5a7ce56232 100644 --- a/packages/server/src/services/mount.ts +++ b/packages/server/src/services/mount.ts @@ -325,7 +325,7 @@ export const deleteFileMount = async (mountId: string) => { const basePath = await getBaseFilesPath(mountId); const fullPath = path.join(basePath, mount.filePath); - + // Validate path is within allowed directory if (!validateFilePath(mount.filePath, basePath)) { throw new TRPCError({ diff --git a/packages/server/src/utils/schedules/utils.ts b/packages/server/src/utils/schedules/utils.ts index 01e46d4768..161307f7ab 100644 --- a/packages/server/src/utils/schedules/utils.ts +++ b/packages/server/src/utils/schedules/utils.ts @@ -180,7 +180,7 @@ export const runCommand = async (scheduleId: string) => { const { SCHEDULES_PATH } = paths(true); const fullPath = path.join(SCHEDULES_PATH, appName || ""); - + // Escape paths and log path for safe shell execution const escapedFullPath = shEscape(fullPath); const escapedLogPath = shEscape(deployment.logPath); diff --git a/packages/server/src/utils/security/path-validation.ts b/packages/server/src/utils/security/path-validation.ts index cd529c50ac..473277d8fe 100644 --- a/packages/server/src/utils/security/path-validation.ts +++ b/packages/server/src/utils/security/path-validation.ts @@ -35,7 +35,10 @@ export function validatePath(userPath: string, basePath: string): boolean { // Ensure the resolved user path starts with the resolved base path // This prevents directory traversal - return resolvedUser.startsWith(resolvedBase + path.sep) || resolvedUser === resolvedBase; + return ( + resolvedUser.startsWith(resolvedBase + path.sep) || + resolvedUser === resolvedBase + ); } catch { // If path resolution fails, it's not safe return false; @@ -85,7 +88,9 @@ export function resolveSafePath(userPath: string, basePath: string): string { try { return path.resolve(basePath, userPath); } catch (error) { - throw new Error(`Failed to resolve path: ${error instanceof Error ? error.message : String(error)}`); + throw new Error( + `Failed to resolve path: ${error instanceof Error ? error.message : String(error)}`, + ); } } @@ -95,7 +100,10 @@ export function resolveSafePath(userPath: string, basePath: string): string { * @param serverId - Optional server ID to determine if remote or local * @returns true if valid, false otherwise */ -export function validateLogPath(logPath: string, serverId?: string | null): boolean { +export function validateLogPath( + logPath: string, + serverId?: string | null, +): boolean { if (!logPath || logPath === ".") { return false; } @@ -117,4 +125,3 @@ export function validateFilePath(filePath: string, basePath: string): boolean { return validatePath(filePath, basePath); } - diff --git a/packages/server/src/utils/security/shell-escape.ts b/packages/server/src/utils/security/shell-escape.ts index 8e41eb0071..8a4a3621ba 100644 --- a/packages/server/src/utils/security/shell-escape.ts +++ b/packages/server/src/utils/security/shell-escape.ts @@ -55,7 +55,15 @@ export function validateContainerId(id: string): boolean { * @returns true if valid, false otherwise */ export function validateShellType(type: string): boolean { - const allowedShells = ["sh", "bash", "zsh", "ash", "dash", "fish", "csh", "tcsh"]; + const allowedShells = [ + "sh", + "bash", + "zsh", + "ash", + "dash", + "fish", + "csh", + "tcsh", + ]; return allowedShells.includes(type.toLowerCase()); } -