From 439f68b07fc113365376dc155044b6eb9ce4855a Mon Sep 17 00:00:00 2001 From: EgerDev <285544328+EgerDev@users.noreply.github.com> Date: Sat, 12 Sep 2026 00:23:13 -0700 Subject: [PATCH] fix(compose): bound custom stack input to the managed file --- .../compose/stack-compose-models.test.ts | 210 +++++++++++------- packages/server/src/utils/docker/domain.ts | 132 +---------- .../server/src/utils/docker/stack-command.ts | 165 ++++++++++++++ 3 files changed, 304 insertions(+), 203 deletions(-) create mode 100644 packages/server/src/utils/docker/stack-command.ts diff --git a/apps/dokploy/__test__/compose/stack-compose-models.test.ts b/apps/dokploy/__test__/compose/stack-compose-models.test.ts index 42f82200a..847e726fa 100644 --- a/apps/dokploy/__test__/compose/stack-compose-models.test.ts +++ b/apps/dokploy/__test__/compose/stack-compose-models.test.ts @@ -15,6 +15,7 @@ import { writeDomainsToCompose, } from "@dokploy/server/utils/docker/domain"; import type { ComposeSpecification } from "@dokploy/server/utils/docker/types"; +import * as processUtils from "@dokploy/server/utils/process/execAsync"; import { afterAll, afterEach, @@ -28,6 +29,7 @@ import { parse, stringify } from "yaml"; const disk = vi.hoisted(() => ({ yaml: "services:\n app:\n image: nginx:alpine\n", + reads: [] as string[], })); vi.mock("node:fs", async (importOriginal) => { @@ -36,6 +38,7 @@ vi.mock("node:fs", async (importOriginal) => { ...actual, existsSync: (p: Parameters[0]) => { const path = String(p); + disk.reads.push(path); if (path.includes("/code/") && path.endsWith("docker-compose.yml")) { return true; } @@ -46,6 +49,7 @@ vi.mock("node:fs", async (importOriginal) => { enc?: BufferEncoding, ) => { const path = String(p); + disk.reads.push(path); if (path.includes("/code/") && path.endsWith("docker-compose.yml")) { return disk.yaml; } @@ -89,6 +93,129 @@ const compose = ( const writeDeploy = (c: ReturnType) => writeDomainsToCompose(c, [], createCommand(c as never)); +describe("managed Stack command regressions", () => { + afterEach(() => vi.restoreAllMocks()); + it.each([ + "-D=false stack deploy", + "-D=true stack deploy", + "-D stack deploy", + "--debug=false stack up", + "-Dlerror stack deploy", + "-l=error stack deploy", + "-- stack deploy", + "stack --orchestrator swarm --orchestrator=swarm deploy", + "stack --orchestrator deploy --orchestrator up deploy", + "-c --something stack --orchestrator=--something deploy", + ])("recognizes and blocks models for %s", async (prefix) => { + const command = `${prefix} -c docker-compose.yml demo`; + expect(isStackDeployCommand(command)).toBe(true); + await expect( + writeDeploy( + compose({ command, composeFile: yaml(spec({ models: {} })) }), + ), + ).rejects.toThrow(STACK_COMPOSE_MODELS_ERROR); + }); + + it.each([ + "-c models.yml", + "-c plain.yml", + "-c docker-compose.yml -c models.yml", + "-c docker-compose.yml --compose-file docker-compose.yml", + "--compose-file=docker-compose.yml,models.yml", + "-c -", + "-c ../outside.yml", + "-c /etc/compose.yml", + "-c subdir/../docker-compose.yml", + "-c linked-compose.yml", + "-c '../outside file.yml'", + "", + ])("rejects unmanaged input without reading files: %s", async (files) => { + disk.reads.length = 0; + await expect( + writeDeploy( + compose({ + sourceType: "github", + command: `stack deploy ${files} demo`, + }), + ), + ).rejects.toThrow(/exactly one.*managed Compose file/); + expect(disk.reads).toEqual([]); + }); + + it("reports selected-file policy before configured models", async () => { + await expect( + writeDeploy( + compose({ + command: "stack deploy -c plain.yml demo", + composeFile: yaml(spec({ models: {} })), + }), + ), + ).rejects.toThrow(/exactly one.*managed Compose file/); + }); + + it("accepts the managed Git path with quoted spaces", async () => { + vi.spyOn(db.query.patch, "findMany").mockResolvedValue([]); + await expect( + writeDeploy( + compose({ + sourceType: "github", + composePath: "dir with spaces/docker-compose.yml", + command: 'stack deploy -c "dir with spaces/docker-compose.yml" demo', + }), + ), + ).resolves.toContain("base64 -d"); + }); + + it("rejects remote alternative input before opening an SSH read", async () => { + const remote = vi + .spyOn(processUtils, "execAsyncRemote") + .mockRejectedValue(new Error("unexpected SSH read")); + await expect( + writeDeploy( + compose({ + sourceType: "github", + serverId: "fixture", + command: "stack deploy -c models.yml demo", + }), + ), + ).rejects.toThrow(/exactly one.*managed Compose file/); + expect(remote).not.toHaveBeenCalled(); + }); + + it("keeps the managed remote document and ordinary transforms", async () => { + vi.spyOn(db.query.patch, "findMany").mockResolvedValue([]); + const remote = vi + .spyOn(processUtils, "execAsyncRemote") + .mockResolvedValue({ stdout: yaml(spec()), stderr: "" }); + await expect( + writeDeploy(compose({ sourceType: "github", serverId: "fixture" })), + ).resolves.toContain("base64 -d"); + expect(remote).toHaveBeenCalledOnce(); + }); + + it.each([ + "stack deploy -cdocker-compose.yml demo", + "stack deploy -c=docker-compose.yml demo", + "stack deploy -qdc./docker-compose.yml demo", + "stack deploy demo --compose-file=./docker-compose.yml", + "stack deploy --resolve-image -c -c docker-compose.yml demo", + "stack deploy --orchestrator swarm -c docker-compose.yml demo", + "stack deploy -c docker-compose.yml -- demo", + ])("accepts one managed input: %s", async (command) => { + await expect(writeDeploy(compose({ command }))).resolves.toContain( + "base64 -d", + ); + }); + + it("does not treat operands after -- as selected inputs", async () => { + await expect( + writeDeploy( + compose({ command: "stack deploy -- demo -c docker-compose.yml" }), + ), + ).rejects.toThrow(/exactly one.*managed Compose file/); + }); +}); + describe("composeSpecificationUsesModels", () => { it("detects top-level and service-level models structurally", () => { expect(composeSpecificationUsesModels(spec())).toBe(false); @@ -760,55 +887,7 @@ const fakeDockerArgv = (command: string, pathPrefix: string) => { return JSON.parse(result.stdout) as string[]; }; -const argvLooksLikeStackDeploy = (argv: string[]) => { - let i = 0; - while (i < argv.length) { - const token = argv[i]; - if (!token || token === "-" || !token.startsWith("-")) break; - if (token === "--") { - i += 1; - break; - } - if ( - token === "-D" || - token === "-v" || - token === "-h" || - token.startsWith("--debug") || - token.startsWith("--tls") || - token.startsWith("--help") || - token.startsWith("--version") - ) { - i += 1; - continue; - } - if ( - token.startsWith("--context") || - token.startsWith("--config") || - token.startsWith("--host") || - token.startsWith("--log-level") || - token.startsWith("--tlscacert") || - token.startsWith("--tlscert") || - token.startsWith("--tlskey") || - token === "-c" || - token.startsWith("-c") || - token === "-H" || - token.startsWith("-H") || - token === "-l" || - token.startsWith("-l") - ) { - if (token.includes("=") || (token.startsWith("-c") && token.length > 2)) { - i += 1; - continue; - } - i += 2; - continue; - } - break; - } - return argv[i] === "stack" && argv[i + 1] === "deploy"; -}; - -describe("fake-docker shell argv differential", () => { +describe("captured Docker shell argv", () => { let fakePath = ""; beforeAll(() => { @@ -827,39 +906,6 @@ process.stdout.write(JSON.stringify(process.argv.slice(2))); if (fakePath) rmSync(fakePath, { recursive: true, force: true }); }); - const corpus = [ - "stack deploy -c docker-compose.yml demo", - "'stack' 'deploy' -c docker-compose.yml demo", - "st\"ack\" de'ploy' -c docker-compose.yml demo", - '--config "/tmp/docker config" stack deploy -c file.yml demo', - "--config '/tmp/docker config' stack deploy -c file.yml demo", - '--context "remote" stack deploy -c file.yml demo', - "-c 'remote' stack deploy -c file.yml demo", - "-D stack deploy -c file.yml demo", - "--debug=false stack deploy -c file.yml demo", - "--context=remote stack deploy -c file.yml demo", - "-c remote stack deploy -c file.yml demo", - "-H unix:///var/run/docker.sock stack deploy -c file.yml demo", - "'compose' up", - "compose run app 'stack' 'deploy'", - "--context remote compose up", - "-D compose up", - "--debug false stack deploy --help", - "-H stack deploy --help", - "compose -f my-stack deploy-file.yml up", - "stack\tdeploy -c file.yml demo", - " stack deploy -c file.yml demo", - ]; - - it("agrees with /bin/sh argv for the supported custom-command corpus", () => { - for (const command of corpus) { - const argv = fakeDockerArgv(command, fakePath); - expect(isStackDeployCommand(command), command).toBe( - argvLooksLikeStackDeploy(argv), - ); - } - }); - it("classifies default createCommand stack deploy as stack deploy in generated-shell argv", () => { const command = createCommand(compose({ command: "" }) as never); expect(command.startsWith("stack deploy")).toBe(true); diff --git a/packages/server/src/utils/docker/domain.ts b/packages/server/src/utils/docker/domain.ts index 1cb787acf..8a2a3af18 100644 --- a/packages/server/src/utils/docker/domain.ts +++ b/packages/server/src/utils/docker/domain.ts @@ -6,7 +6,7 @@ import { network, patch } from "@dokploy/server/db/schema"; import type { Compose } from "@dokploy/server/services/compose"; import type { Domain } from "@dokploy/server/services/domain"; import { eq, inArray } from "drizzle-orm"; -import { parse as parseShell, quote } from "shell-quote"; +import { quote } from "shell-quote"; import { parse, stringify } from "yaml"; import { execAsyncRemote } from "../process/execAsync"; import { cloneBitbucketRepository } from "../providers/bitbucket"; @@ -17,6 +17,10 @@ import { cloneGitlabRepository } from "../providers/gitlab"; import { getCreateComposeFileCommand } from "../providers/raw"; import { randomizeDeployableSpecificationFile } from "./collision"; import { randomizeSpecificationFile } from "./compose"; +import { + isStackDeployCommand, + validateStackComposeInput, +} from "./stack-command"; import type { ComposeSpecification, DefinitionsService, @@ -24,6 +28,8 @@ import type { } from "./types"; import { encodeBase64 } from "./utils"; +export { isStackDeployCommand } from "./stack-command"; + export const cloneCompose = async (compose: Compose) => { let command = "set -e;"; const entity = { @@ -132,131 +138,15 @@ export const composeSpecificationUsesModels = ( return false; }; -const DOCKER_VALUE_LONG = new Set([ - "config", - "context", - "host", - "log-level", - "tlscacert", - "tlscert", - "tlskey", -]); -const DOCKER_VALUE_SHORT = new Set(["c", "H", "l"]); -const DOCKER_BOOL_LONG = new Set([ - "debug", - "help", - "tls", - "tlsverify", - "version", -]); -const DOCKER_BOOL_SHORT = new Set(["D", "h", "v"]); - -const tokenizeDockerCommand = (command: string) => { - const tokens: string[] = []; - for (const entry of parseShell(command, {})) { - if (typeof entry === "string") { - tokens.push(entry); - continue; - } - if ("comment" in entry) continue; - if ("op" in entry) { - if (entry.op === "glob") { - tokens.push(entry.pattern); - continue; - } - if (entry.op === "&&") break; - return []; - } - } - return tokens; -}; - -const skipDockerGlobalOptions = (tokens: string[]) => { - let i = 0; - while (i < tokens.length) { - const token = tokens[i]; - if (!token || token === "-") break; - if (token === "--") return i + 1; - if (!token.startsWith("-")) break; - - if (token.startsWith("--")) { - const eq = token.indexOf("="); - const name = eq === -1 ? token.slice(2) : token.slice(2, eq); - if (DOCKER_BOOL_LONG.has(name)) { - i += 1; - continue; - } - if (DOCKER_VALUE_LONG.has(name)) { - if (eq !== -1) { - i += 1; - continue; - } - if (i + 1 >= tokens.length) return -1; - i += 2; - continue; - } - return -1; - } - - let k = 1; - let consumeNextValue = false; - while (k < token.length) { - const flag = token[k]; - if (!flag) break; - if (DOCKER_BOOL_SHORT.has(flag)) { - k += 1; - continue; - } - if (DOCKER_VALUE_SHORT.has(flag)) { - if (k + 1 < token.length) { - consumeNextValue = false; - k = token.length; - break; - } - consumeNextValue = true; - break; - } - return -1; - } - if (consumeNextValue) { - if (i + 1 >= tokens.length) return -1; - i += 2; - continue; - } - i += 1; - } - return i; -}; - -const skipStackOrchestratorOption = (tokens: string[], i: number) => { - const token = tokens[i]; - if (!token) return i; - if (token === "--orchestrator") { - if (i + 1 >= tokens.length) return -1; - return i + 2; - } - if (token.startsWith("--orchestrator=")) return i + 1; - return i; -}; - -// Docker documents `stack up` as an alias of `stack deploy` (CLI 28.5+ / 29.x). -// Deprecated `--orchestrator` is a stack-level string flag that may sit between -// `stack` and `deploy`/`up`; `stack --orchestrator deploy` consumes `deploy` as -// the flag value and is not a deployment. -export const isStackDeployCommand = (command: string) => { - const tokens = tokenizeDockerCommand(command); - const i = skipDockerGlobalOptions(tokens); - if (i < 0 || tokens[i] !== "stack") return false; - const j = skipStackOrchestratorOption(tokens, i + 1); - if (j < 0) return false; - return tokens[j] === "deploy" || tokens[j] === "up"; -}; - export const writeDomainsToCompose = async ( compose: Compose, domains: Domain[], dockerCommand = "", ) => { + validateStackComposeInput( + dockerCommand, + compose.sourceType === "raw" ? "docker-compose.yml" : compose.composePath, + ); let composeConverted: ComposeSpecification | null; try { composeConverted = await addDomainToCompose(compose, domains); diff --git a/packages/server/src/utils/docker/stack-command.ts b/packages/server/src/utils/docker/stack-command.ts new file mode 100644 index 000000000..06e0bfb61 --- /dev/null +++ b/packages/server/src/utils/docker/stack-command.ts @@ -0,0 +1,165 @@ +import { posix } from "node:path"; +import { parse } from "shell-quote"; + +type OptionKind = "boolean" | "value" | "file"; +type Options = Readonly>; + +const ROOT_OPTIONS: Options = { + "--config": "value", + "--context": "value", + "-c": "value", + "--host": "value", + "-H": "value", + "--log-level": "value", + "-l": "value", + "--tlscacert": "value", + "--tlscert": "value", + "--tlskey": "value", + "--debug": "boolean", + "-D": "boolean", + "--help": "boolean", + "-h": "boolean", + "--tls": "boolean", + "--tlsverify": "boolean", + "--version": "boolean", + "-v": "boolean", +}; +const STACK_OPTIONS: Options = { "--orchestrator": "value" }; +const DEPLOY_OPTIONS: Options = { + ...STACK_OPTIONS, + "--compose-file": "file", + "-c": "file", + "--resolve-image": "value", + "--detach": "boolean", + "-d": "boolean", + "--quiet": "boolean", + "-q": "boolean", + "--prune": "boolean", + "--with-registry-auth": "boolean", + "--help": "boolean", + "-h": "boolean", +}; + +const tokenize = (command: string): string[] => { + const tokens: string[] = []; + for (const entry of parse(command, {})) { + if (typeof entry === "string") tokens.push(entry); + else if ("op" in entry) { + if (entry.op === "glob") tokens.push(entry.pattern); + else if (entry.op === "&&") break; + else return []; + } + } + return tokens; +}; + +// Docker's short boolean flags accept =VALUE; value flags consume the rest +// of a short cluster or the next token, even when that token starts with '-'. +const consumeOption = ( + tokens: readonly string[], + index: number, + options: Options, +): { next: number; file?: string } | null => { + const token = tokens[index]; + if (!token?.startsWith("-") || token === "-" || token === "--") return null; + const long = token.startsWith("--"); + let offset = long ? 2 : 1; + while (offset < token.length) { + const equals = token.indexOf("=", offset); + const name = long + ? token.slice(0, equals < 0 ? undefined : equals) + : `-${token[offset]}`; + const kind = options[name]; + if (!kind) return null; + const rest = long + ? equals < 0 + ? "" + : token.slice(equals) + : token.slice(offset + 1); + if (kind === "boolean") { + if (long || rest.startsWith("=")) return { next: index + 1 }; + offset += 1; + continue; + } + const attached = rest.length > 0; + const value = attached ? rest.replace(/^=/, "") : tokens[index + 1]; + if (value === undefined) return null; + return { + next: index + (attached ? 1 : 2), + ...(kind === "file" ? { file: value } : {}), + }; + } + return { next: index + 1 }; +}; + +const commandIndex = ( + tokens: readonly string[], + start: number, + options: Options, + root = false, +): number => { + let index = start; + while (tokens[index]?.startsWith("-")) { + if (root && tokens[index] === "--") return index + 1; + const option = consumeOption(tokens, index, options); + if (!option) return -1; + index = option.next; + } + return index; +}; + +const deploymentIndex = (tokens: readonly string[]): number => { + const stack = commandIndex(tokens, 0, ROOT_OPTIONS, true); + if (stack < 0 || tokens[stack] !== "stack") return -1; + const deploy = commandIndex(tokens, stack + 1, STACK_OPTIONS); + return deploy >= 0 && (tokens[deploy] === "deploy" || tokens[deploy] === "up") + ? deploy + : -1; +}; + +export const isStackDeployCommand = (command: string): boolean => + deploymentIndex(tokenize(command)) >= 0; + +export const STACK_COMPOSE_INPUT_ERROR = + "Custom Stack deployments must select exactly one Dokploy-managed Compose file. Use the configured Compose path with -c; alternate files, multiple files, CSV lists, stdin, absolute paths and parent traversal are not supported."; + +const isManagedPath = (selected: string, managed: string): boolean => { + if ( + !selected || + selected === "-" || + /[,"]/.test(selected) || + posix.isAbsolute(selected) || + selected.split("/").includes("..") + ) + return false; + return posix.normalize(selected) === posix.normalize(managed); +}; + +// Only the configured document receives Dokploy's patches, domains and name +// transforms. Confining Stack input avoids reading or merging arbitrary files. +export const validateStackComposeInput = ( + command: string, + managedPath: string, +): void => { + const tokens = tokenize(command); + const deploy = deploymentIndex(tokens); + if (deploy < 0) return; + const files: string[] = []; + let index = deploy + 1; + while (index < tokens.length) { + if (tokens[index] === "--") break; + if (!tokens[index]?.startsWith("-")) { + index += 1; + continue; + } + const option = consumeOption(tokens, index, DEPLOY_OPTIONS); + if (!option) throw new Error(STACK_COMPOSE_INPUT_ERROR); + if (option.file !== undefined) files.push(option.file); + index = option.next; + } + if ( + files.length !== 1 || + !files.every((file) => isManagedPath(file, managedPath)) + ) + throw new Error(STACK_COMPOSE_INPUT_ERROR); +};