Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 13 additions & 8 deletions apps/dokploy/server/api/routers/docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,23 @@ 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(
Expand All @@ -34,10 +45,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 }) => {
Expand All @@ -47,10 +55,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(),
}),
)
Expand Down
32 changes: 29 additions & 3 deletions apps/dokploy/server/wss/docker-container-terminal.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);

Expand All @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -120,9 +141,14 @@ export const setupDockerContainerTerminalWebSocketServer = (
});
} else {
const shell = getShell();
// Use array arguments instead of shell string to prevent injection
// docker exec -it -w / <containerId> <shell>
const ptyProcess = spawn(
shell,
["-c", `docker exec -it -w / ${containerId} ${activeWay}`],
[
"-c",
`docker exec -it -w / ${shEscape(containerId)} ${shEscape(activeWay)}`,
],
{},
);

Expand Down
14 changes: 11 additions & 3 deletions packages/server/src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
44 changes: 35 additions & 9 deletions packages/server/src/services/deployment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
}
Expand Down
28 changes: 26 additions & 2 deletions packages/server/src/services/mount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 {
Expand All @@ -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);
Expand Down
7 changes: 3 additions & 4 deletions packages/server/src/services/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions packages/server/src/utils/process/execAsync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
52 changes: 43 additions & 9 deletions packages/server/src/utils/schedules/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand All @@ -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],
Expand Down Expand Up @@ -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
Expand Down
Loading