fix(compose): bound custom stack input to the managed file

This commit is contained in:
EgerDev 2026-09-12 00:23:13 -07:00
parent 69a007a175
commit 439f68b07f
3 changed files with 304 additions and 203 deletions

View File

@ -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<typeof actual.existsSync>[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<typeof compose>) =>
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);

View File

@ -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);

View File

@ -0,0 +1,165 @@
import { posix } from "node:path";
import { parse } from "shell-quote";
type OptionKind = "boolean" | "value" | "file";
type Options = Readonly<Record<string, OptionKind>>;
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);
};