mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-09-12 19:51:00 +05:00
fix(compose): detect stack deploy after Docker globals and shell quoting
Skip Docker root global options and tokenize custom commands with shell-quote.parse so quoted argv and --context/--host forms cannot bypass the Swarm models guard.
This commit is contained in:
parent
cac1325236
commit
8e3b9ef89e
@ -1,3 +1,7 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
createCommand,
|
||||
@ -11,7 +15,15 @@ import {
|
||||
writeDomainsToCompose,
|
||||
} from "@dokploy/server/utils/docker/domain";
|
||||
import type { ComposeSpecification } from "@dokploy/server/utils/docker/types";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { parse, stringify } from "yaml";
|
||||
|
||||
const disk = vi.hoisted(() => ({
|
||||
@ -142,6 +154,112 @@ services:
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("skips Docker root global options before stack deploy", () => {
|
||||
const file = "stack deploy -c docker-compose.yml demo";
|
||||
expect(isStackDeployCommand(`--context remote ${file}`)).toBe(true);
|
||||
expect(isStackDeployCommand(`--context=remote ${file}`)).toBe(true);
|
||||
expect(isStackDeployCommand(`-c remote ${file}`)).toBe(true);
|
||||
expect(isStackDeployCommand(`-c=remote ${file}`)).toBe(true);
|
||||
expect(isStackDeployCommand(`-cremote ${file}`)).toBe(true);
|
||||
expect(isStackDeployCommand(`-D ${file}`)).toBe(true);
|
||||
expect(isStackDeployCommand(`--debug ${file}`)).toBe(true);
|
||||
expect(isStackDeployCommand(`--debug=true ${file}`)).toBe(true);
|
||||
expect(isStackDeployCommand(`--tlsverify ${file}`)).toBe(true);
|
||||
expect(isStackDeployCommand(`--config /tmp/config ${file}`)).toBe(true);
|
||||
expect(isStackDeployCommand(`--config=deploy ${file}`)).toBe(true);
|
||||
expect(isStackDeployCommand(`-H unix:///var/run/docker.sock ${file}`)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isStackDeployCommand(`-Hunix:///var/run/docker.sock ${file}`)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isStackDeployCommand(`--context remote -D --tlsverify ${file}`),
|
||||
).toBe(true);
|
||||
expect(isStackDeployCommand(`-- ${file}`)).toBe(true);
|
||||
expect(isStackDeployCommand(`-Dc remote ${file}`)).toBe(true);
|
||||
expect(isStackDeployCommand(`--config deploy ${file}`)).toBe(true);
|
||||
expect(isStackDeployCommand(`--log-level=debug ${file}`)).toBe(true);
|
||||
expect(isStackDeployCommand(`-v ${file}`)).toBe(true);
|
||||
expect(isStackDeployCommand(`--config stack ${file}`)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat option values or compose args as stack deploy", () => {
|
||||
expect(isStackDeployCommand("--context remote compose up")).toBe(false);
|
||||
expect(isStackDeployCommand("-D compose up")).toBe(false);
|
||||
expect(isStackDeployCommand("compose run app stack deploy")).toBe(false);
|
||||
expect(isStackDeployCommand("compose exec app stack deploy")).toBe(false);
|
||||
expect(isStackDeployCommand("compose -f my-stack deploy-file.yml up")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isStackDeployCommand("--context stack compose up")).toBe(false);
|
||||
expect(isStackDeployCommand("compose run app stack deploy")).toBe(false);
|
||||
expect(isStackDeployCommand("--context")).toBe(false);
|
||||
expect(isStackDeployCommand("--config")).toBe(false);
|
||||
expect(isStackDeployCommand("-H")).toBe(false);
|
||||
expect(isStackDeployCommand("-l")).toBe(false);
|
||||
expect(
|
||||
isStackDeployCommand(
|
||||
"--something-new value stack deploy -c file.yml demo",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(isStackDeployCommand("stack --context remote deploy")).toBe(false);
|
||||
expect(isStackDeployCommand("-c stack deploy -c file.yml demo")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isStackDeployCommand("--debug false stack deploy --help")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isStackDeployCommand("-H stack deploy --help")).toBe(false);
|
||||
expect(isStackDeployCommand("-cD remote stack deploy --help")).toBe(false);
|
||||
});
|
||||
|
||||
it("classifies quoted shell argv the way /bin/sh does", () => {
|
||||
expect(
|
||||
isStackDeployCommand("'stack' 'deploy' -c docker-compose.yml demo"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isStackDeployCommand("st\"ack\" de'ploy' -c docker-compose.yml demo"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isStackDeployCommand(
|
||||
'--config "/tmp/docker config" stack deploy -c file.yml demo',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isStackDeployCommand(
|
||||
"--config '/tmp/docker config' stack deploy -c file.yml demo",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isStackDeployCommand('--context "remote" stack deploy -c file.yml demo'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isStackDeployCommand("-c 'remote' stack deploy -c file.yml demo"),
|
||||
).toBe(true);
|
||||
expect(isStackDeployCommand("'compose' up")).toBe(false);
|
||||
expect(isStackDeployCommand("compose run app 'stack' 'deploy'")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isStackDeployCommand("stack\tdeploy -c file.yml demo")).toBe(true);
|
||||
expect(isStackDeployCommand('--context="" stack deploy --help')).toBe(true);
|
||||
expect(isStackDeployCommand("--context= stack deploy --help")).toBe(true);
|
||||
expect(isStackDeployCommand("--context '' stack deploy --help")).toBe(true);
|
||||
expect(isStackDeployCommand("-c '' stack deploy --help")).toBe(true);
|
||||
expect(
|
||||
isStackDeployCommand("stack deploy -c file.yml demo # trailing comment"),
|
||||
).toBe(true);
|
||||
expect(isStackDeployCommand("# stack deploy -c file.yml demo")).toBe(false);
|
||||
expect(isStackDeployCommand("~ stack deploy --help")).toBe(false);
|
||||
expect(isStackDeployCommand("'' stack deploy --help")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not crash on malformed quotes", () => {
|
||||
expect(() => isStackDeployCommand("'stack deploy")).not.toThrow();
|
||||
expect(() => isStackDeployCommand('"stack deploy')).not.toThrow();
|
||||
expect(() => isStackDeployCommand('stack "deploy')).not.toThrow();
|
||||
});
|
||||
|
||||
it("does not crash on malformed specs", () => {
|
||||
expect(composeSpecificationUsesModels(null)).toBe(false);
|
||||
expect(composeSpecificationUsesModels(undefined)).toBe(false);
|
||||
@ -300,6 +418,92 @@ services:
|
||||
),
|
||||
).resolves.toContain("base64 -d");
|
||||
});
|
||||
|
||||
it("rejects stack deploy after Docker root global options when models are present", async () => {
|
||||
const models = yaml(spec({ models: { llm: { model: "ai/smollm2" } } }));
|
||||
for (const command of [
|
||||
"--context remote stack deploy -c docker-compose.yml demo",
|
||||
"--context=remote stack deploy -c docker-compose.yml demo",
|
||||
"-c remote stack deploy -c docker-compose.yml demo",
|
||||
"-D stack deploy -c docker-compose.yml demo",
|
||||
"--tlsverify stack deploy -c docker-compose.yml demo",
|
||||
"--config /tmp/config stack deploy -c docker-compose.yml demo",
|
||||
"-H unix:///var/run/docker.sock stack deploy -c docker-compose.yml demo",
|
||||
"--context remote -D --tlsverify stack deploy -c docker-compose.yml demo",
|
||||
]) {
|
||||
await expect(
|
||||
writeDeploy(
|
||||
compose({
|
||||
composeType: "docker-compose",
|
||||
command,
|
||||
composeFile: models,
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow(STACK_COMPOSE_MODELS_ERROR);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects quoted stack deploy custom commands when models are present", async () => {
|
||||
const models = yaml(spec({ models: { llm: { model: "ai/smollm2" } } }));
|
||||
await expect(
|
||||
writeDeploy(
|
||||
compose({
|
||||
composeType: "docker-compose",
|
||||
command: "'stack' 'deploy' -c docker-compose.yml demo",
|
||||
composeFile: models,
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow(STACK_COMPOSE_MODELS_ERROR);
|
||||
await expect(
|
||||
writeDeploy(
|
||||
compose({
|
||||
composeType: "docker-compose",
|
||||
command:
|
||||
'--config "/tmp/docker config" stack deploy -c docker-compose.yml demo',
|
||||
composeFile: models,
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow(STACK_COMPOSE_MODELS_ERROR);
|
||||
});
|
||||
|
||||
it("rejects later && docker stack deploy at sanitize time", () => {
|
||||
expect(() =>
|
||||
createCommand(
|
||||
compose({
|
||||
command: "compose up -d && docker stack deploy -c file.yml demo",
|
||||
}) as never,
|
||||
),
|
||||
).toThrow(/Chained commands must strictly start with 'docker compose '/);
|
||||
expect(() =>
|
||||
createCommand(
|
||||
compose({
|
||||
command:
|
||||
"compose up -d && docker --context remote stack deploy -c file.yml demo",
|
||||
}) as never,
|
||||
),
|
||||
).toThrow(/Chained commands must strictly start with 'docker compose '/);
|
||||
});
|
||||
|
||||
it("allows compose commands that only resemble stack deploy after globals", async () => {
|
||||
const models = yaml(spec({ models: { llm: { model: "ai/smollm2" } } }));
|
||||
for (const command of [
|
||||
"--context remote compose up -d",
|
||||
"-D compose up -d",
|
||||
"compose run app stack deploy",
|
||||
"compose exec app stack deploy",
|
||||
"--context stack compose up -d",
|
||||
]) {
|
||||
await expect(
|
||||
writeDeploy(
|
||||
compose({
|
||||
composeType: "docker-compose",
|
||||
command,
|
||||
composeFile: models,
|
||||
}),
|
||||
),
|
||||
).resolves.toContain("base64 -d");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("compose-file patch order", () => {
|
||||
@ -411,4 +615,157 @@ describe("getBuildComposeCommand stack models", () => {
|
||||
),
|
||||
).rejects.toThrow(STACK_COMPOSE_MODELS_ERROR);
|
||||
});
|
||||
|
||||
it("does not emit stack deploy when global options precede stack deploy and models are present", async () => {
|
||||
await expect(
|
||||
getBuildComposeCommand(
|
||||
buildArgs({
|
||||
composeType: "docker-compose",
|
||||
command: "--context remote stack deploy -c docker-compose.yml demo",
|
||||
composeFile: yaml(spec({ models: { llm: { model: "ai/smollm2" } } })),
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow(STACK_COMPOSE_MODELS_ERROR);
|
||||
});
|
||||
});
|
||||
|
||||
const fakeDockerArgv = (command: string, pathPrefix: string) => {
|
||||
const result = spawnSync("sh", ["-c", `docker ${command}`], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, PATH: `${pathPrefix}:${process.env.PATH ?? ""}` },
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(result.stderr || `fake docker failed: ${result.status}`);
|
||||
}
|
||||
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", () => {
|
||||
let fakePath = "";
|
||||
|
||||
beforeAll(() => {
|
||||
fakePath = mkdtempSync(join(tmpdir(), "dokploy-fake-docker-"));
|
||||
const bin = join(fakePath, "docker");
|
||||
writeFileSync(
|
||||
bin,
|
||||
`#!/usr/bin/env node
|
||||
process.stdout.write(JSON.stringify(process.argv.slice(2)));
|
||||
`,
|
||||
);
|
||||
chmodSync(bin, 0o755);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
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);
|
||||
expect(isStackDeployCommand(command)).toBe(true);
|
||||
const argv = fakeDockerArgv(command, fakePath);
|
||||
expect(argv[0]).toBe("stack");
|
||||
expect(argv[1]).toBe("deploy");
|
||||
});
|
||||
|
||||
it("expands quoted stack deploy to stack/deploy argv", () => {
|
||||
expect(
|
||||
fakeDockerArgv("'stack' 'deploy' -c file.yml demo", fakePath),
|
||||
).toEqual(["stack", "deploy", "-c", "file.yml", "demo"]);
|
||||
expect(
|
||||
fakeDockerArgv(
|
||||
'--config "/tmp/docker config" stack deploy -c file.yml demo',
|
||||
fakePath,
|
||||
),
|
||||
).toEqual([
|
||||
"--config",
|
||||
"/tmp/docker config",
|
||||
"stack",
|
||||
"deploy",
|
||||
"-c",
|
||||
"file.yml",
|
||||
"demo",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 { quote } from "shell-quote";
|
||||
import { parse as parseShell, quote } from "shell-quote";
|
||||
import { parse, stringify } from "yaml";
|
||||
import { execAsyncRemote } from "../process/execAsync";
|
||||
import { cloneBitbucketRepository } from "../providers/bitbucket";
|
||||
@ -132,9 +132,107 @@ 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;
|
||||
};
|
||||
|
||||
export const isStackDeployCommand = (command: string) => {
|
||||
const [first, second] = command.trim().split(/\s+/);
|
||||
return first === "stack" && second === "deploy";
|
||||
const tokens = tokenizeDockerCommand(command);
|
||||
const i = skipDockerGlobalOptions(tokens);
|
||||
if (i < 0) return false;
|
||||
return tokens[i] === "stack" && tokens[i + 1] === "deploy";
|
||||
};
|
||||
|
||||
export const writeDomainsToCompose = async (
|
||||
|
||||
Loading…
Reference in New Issue
Block a user