Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
311 changes: 310 additions & 1 deletion apps/dokploy/__test__/env/environment.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { prepareEnvironmentVariables } from "@dokploy/server/index";
import {
prepareEnvironmentVariables,
prepareEnvironmentVariablesForShell,
} from "@dokploy/server/index";
import { describe, expect, it } from "vitest";

const projectEnv = `
Expand Down Expand Up @@ -332,4 +335,310 @@ IS_DEV=\${{environment.DEVELOPMENT}}
"IS_DEV=0",
]);
});

it("handles environment variables with single quotes in values", () => {
const envWithSingleQuotes = `
ENV_VARIABLE='ENVITONME'NT'
ANOTHER_VAR='value with 'quotes' inside'
SIMPLE_VAR=no-quotes
`;

const serviceWithSingleQuotes = `
TEST_VAR=\${{environment.ENV_VARIABLE}}
ANOTHER_TEST=\${{environment.ANOTHER_VAR}}
SIMPLE=\${{environment.SIMPLE_VAR}}
`;

const resolved = prepareEnvironmentVariables(
serviceWithSingleQuotes,
"",
envWithSingleQuotes,
);

expect(resolved).toEqual([
"TEST_VAR=ENVITONME'NT",
"ANOTHER_TEST=value with 'quotes' inside",
"SIMPLE=no-quotes",
]);
});
});

describe("prepareEnvironmentVariablesForShell (shell escaping)", () => {
it("escapes single quotes in environment variable values", () => {
const serviceEnv = `
ENV_VARIABLE='ENVITONME'NT'
ANOTHER_VAR='value with 'quotes' inside'
`;

const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");

// shell-quote should wrap these in double quotes
expect(resolved).toEqual([
`"ENV_VARIABLE=ENVITONME'NT"`,
`"ANOTHER_VAR=value with 'quotes' inside"`,
]);
});

it("escapes double quotes in environment variable values", () => {
const serviceEnv = `
MESSAGE="Hello "World""
QUOTED_PATH="/path/to/"file""
`;

const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");

// shell-quote wraps in single quotes when there are double quotes inside
expect(resolved).toEqual([
`'MESSAGE=Hello "World"'`,
`'QUOTED_PATH=/path/to/"file"'`,
]);
});

it("escapes dollar signs in environment variable values", () => {
const serviceEnv = `
PRICE=$100
VARIABLE=$HOME/path
TEMPLATE=Hello $USER
`;

const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");

// Dollar signs should be escaped to prevent variable expansion
for (const env of resolved) {
expect(env).toContain("$");
}
});

it("escapes backticks in environment variable values", () => {
const serviceEnv = `
COMMAND=\`echo "test"\`
NESTED=value with \`backticks\` inside
`;

const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");

// Backticks are escaped/removed by dotenv parsing, but values should be safely quoted
expect(resolved.length).toBe(2);
expect(resolved[0]).toContain("COMMAND");
expect(resolved[1]).toContain("NESTED");
});

it("handles environment variables with spaces", () => {
const serviceEnv = `
FULL_NAME="John Doe"
MESSAGE='Hello World'
SENTENCE=This is a test
`;

const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");

// shell-quote uses single quotes for strings with spaces
expect(resolved).toEqual([
`'FULL_NAME=John Doe'`,
`'MESSAGE=Hello World'`,
`'SENTENCE=This is a test'`,
]);
});

it("handles environment variables with backslashes", () => {
const serviceEnv = `
WINDOWS_PATH=C:\\Users\\Documents
ESCAPED=value\\with\\backslashes
`;

const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");

// Backslashes should be properly escaped
expect(resolved.length).toBe(2);
for (const env of resolved) {
expect(env).toContain("\\");
}
});

it("handles simple environment variables without special characters", () => {
const serviceEnv = `
NODE_ENV=production
PORT=3000
DEBUG=true
`;

const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");

// shell-quote escapes the = sign in some cases
expect(resolved).toEqual([
"NODE_ENV\\=production",
"PORT\\=3000",
"DEBUG\\=true",
]);
});

it("handles environment variables with mixed special characters", () => {
const serviceEnv = `
COMPLEX='value with "double" and 'single' quotes'
BASH_COMMAND=echo "$HOME" && echo 'test'
WEIRD=\`echo "$VAR"\` with 'quotes' and "more"
`;

const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");

// All should be escaped, none should throw errors
expect(resolved.length).toBe(3);
// Verify each can be safely used in shell
for (const env of resolved) {
expect(typeof env).toBe("string");
expect(env.length).toBeGreaterThan(0);
}
});

it("handles environment variables with newlines", () => {
const serviceEnv = `
MULTILINE="line1
line2
line3"
`;

const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");

expect(resolved.length).toBe(1);
expect(resolved[0]).toContain("MULTILINE");
});

it("handles empty environment variable values", () => {
const serviceEnv = `
EMPTY=
EMPTY_QUOTED=""
EMPTY_SINGLE=''
`;

const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");

// shell-quote escapes the = sign for empty values
expect(resolved).toEqual([
"EMPTY\\=",
"EMPTY_QUOTED\\=",
"EMPTY_SINGLE\\=",
]);
});

it("handles environment variables with equals signs in values", () => {
const serviceEnv = `
EQUATION=a=b+c
CONNECTION_STRING=user=admin;password=test
`;

const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");

expect(resolved.length).toBe(2);
expect(resolved[0]).toContain("EQUATION");
expect(resolved[1]).toContain("CONNECTION_STRING");
});

it("resolves and escapes environment variables together", () => {
const projectEnv = `
BASE_URL=https://example.com
API_KEY='secret-key-with-quotes'
`;

const environmentEnv = `
ENV_NAME=production
DB_PASS='pa$$word'
`;

const serviceEnv = `
FULL_URL=\${{project.BASE_URL}}/api
AUTH_KEY=\${{project.API_KEY}}
ENVIRONMENT=\${{environment.ENV_NAME}}
DB_PASSWORD=\${{environment.DB_PASS}}
CUSTOM='value with 'quotes' inside'
`;

const resolved = prepareEnvironmentVariablesForShell(
serviceEnv,
projectEnv,
environmentEnv,
);

expect(resolved.length).toBe(5);
// All resolved values should be properly escaped
for (const env of resolved) {
expect(typeof env).toBe("string");
}
});

it("handles environment variables with semicolons and ampersands", () => {
const serviceEnv = `
COMMAND=echo "test" && echo "test2"
MULTIPLE=cmd1; cmd2; cmd3
URL_WITH_PARAMS=https://example.com?a=1&b=2&c=3
`;

const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");

expect(resolved.length).toBe(3);
// These should be safely escaped to prevent command injection
for (const env of resolved) {
expect(typeof env).toBe("string");
expect(env.length).toBeGreaterThan(0);
}
});

it("handles environment variables with pipes and redirects", () => {
const serviceEnv = `
PIPE_COMMAND=cat file | grep test
REDIRECT=echo "test" > output.txt
BOTH=cat input.txt | grep pattern > output.txt
`;

const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");

expect(resolved.length).toBe(3);
// Pipes and redirects should be safely quoted
expect(resolved[0]).toContain("PIPE_COMMAND");
expect(resolved[1]).toContain("REDIRECT");
expect(resolved[2]).toContain("BOTH");
// At least one should contain a pipe
const hasPipe = resolved.some((env) => env.includes("|"));
expect(hasPipe).toBe(true);
});

it("handles environment variables with parentheses and brackets", () => {
const serviceEnv = `
MATH=(a+b)*c
ARRAY=[1,2,3]
JSON={"key":"value"}
`;

const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");

expect(resolved.length).toBe(3);
expect(resolved[0]).toContain("(");
expect(resolved[1]).toContain("[");
expect(resolved[2]).toContain("{");
});

it("handles very long environment variable values", () => {
const longValue = "a".repeat(10000);
const serviceEnv = `LONG_VAR=${longValue}`;

const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");

expect(resolved.length).toBe(1);
expect(resolved[0]).toContain("LONG_VAR");
expect(resolved[0]?.length).toBeGreaterThan(10000);
});

it("handles special unicode characters in environment variables", () => {
const serviceEnv = `
EMOJI=Hello 🌍 World πŸš€
CHINESE=δ½ ε₯½δΈ–η•Œ
SPECIAL=cafΓ© rΓ©sumΓ© naΓ―ve
`;

const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");

expect(resolved.length).toBe(3);
expect(resolved[0]).toContain("🌍");
expect(resolved[1]).toContain("δ½ ε₯½");
expect(resolved[2]).toContain("cafΓ©");
});
});
4 changes: 3 additions & 1 deletion apps/dokploy/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.25.8",
"version": "v0.25.9",
"private": true,
"license": "Apache-2.0",
"type": "module",
Expand Down Expand Up @@ -98,6 +98,7 @@
"bl": "6.0.11",
"boxen": "^7.1.1",
"bullmq": "5.4.2",
"shell-quote": "^1.8.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^0.2.1",
Expand Down Expand Up @@ -157,6 +158,7 @@
"zod-form-data": "^2.0.7"
},
"devDependencies": {
"@types/shell-quote": "^1.7.5",
"@types/adm-zip": "^0.5.7",
"@types/bcrypt": "5.0.2",
"@types/js-cookie": "^3.0.6",
Expand Down
2 changes: 2 additions & 0 deletions packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
"react": "18.2.0",
"react-dom": "18.2.0",
"rotating-file-stream": "3.2.3",
"shell-quote": "^1.8.1",
"slugify": "^1.6.6",
"ssh2": "1.15.0",
"toml": "3.0.0",
Expand All @@ -93,6 +94,7 @@
"@types/qrcode": "^1.5.5",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@types/shell-quote": "^1.7.5",
"@types/ssh2": "1.15.1",
"@types/ws": "8.5.10",
"drizzle-kit": "^0.30.6",
Expand Down
15 changes: 9 additions & 6 deletions packages/server/src/services/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@ export const removeCompose = async (
} else {
const command = `
docker network disconnect ${compose.appName} dokploy-traefik;
cd ${projectPath} && docker compose -p ${compose.appName} down ${
cd ${projectPath} && env -i PATH="$PATH" docker compose -p ${compose.appName} down ${
deleteVolumes ? "--volumes" : ""
} && rm -rf ${projectPath}`;

Expand All @@ -402,7 +402,7 @@ export const startCompose = async (composeId: string) => {
const projectPath = join(COMPOSE_PATH, compose.appName, "code");
const path =
compose.sourceType === "raw" ? "docker-compose.yml" : compose.composePath;
const baseCommand = `docker compose -p ${compose.appName} -f ${path} up -d`;
const baseCommand = `env -i PATH="$PATH" docker compose -p ${compose.appName} -f ${path} up -d`;
if (compose.composeType === "docker-compose") {
if (compose.serverId) {
await execAsyncRemote(
Expand Down Expand Up @@ -437,14 +437,17 @@ export const stopCompose = async (composeId: string) => {
if (compose.serverId) {
await execAsyncRemote(
compose.serverId,
`cd ${join(COMPOSE_PATH, compose.appName)} && docker compose -p ${
`cd ${join(COMPOSE_PATH, compose.appName)} && env -i PATH="$PATH" docker compose -p ${
compose.appName
} stop`,
);
} else {
await execAsync(`docker compose -p ${compose.appName} stop`, {
cwd: join(COMPOSE_PATH, compose.appName),
});
await execAsync(
`env -i PATH="$PATH" docker compose -p ${compose.appName} stop`,
{
cwd: join(COMPOSE_PATH, compose.appName),
},
);
}
}

Expand Down
Loading