Skip to content

Commit 1c9dcc0

Browse files
authored
Merge pull request #3066 from Dokploy/fix/nixpacks-builder
feat: enhance environment variable handling for shell commands
2 parents 42a4cc7 + fee802a commit 1c9dcc0

File tree

10 files changed

+367
-19
lines changed

10 files changed

+367
-19
lines changed

apps/dokploy/__test__/env/environment.test.ts

Lines changed: 310 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { prepareEnvironmentVariables } from "@dokploy/server/index";
1+
import {
2+
prepareEnvironmentVariables,
3+
prepareEnvironmentVariablesForShell,
4+
} from "@dokploy/server/index";
25
import { describe, expect, it } from "vitest";
36

47
const projectEnv = `
@@ -332,4 +335,310 @@ IS_DEV=\${{environment.DEVELOPMENT}}
332335
"IS_DEV=0",
333336
]);
334337
});
338+
339+
it("handles environment variables with single quotes in values", () => {
340+
const envWithSingleQuotes = `
341+
ENV_VARIABLE='ENVITONME'NT'
342+
ANOTHER_VAR='value with 'quotes' inside'
343+
SIMPLE_VAR=no-quotes
344+
`;
345+
346+
const serviceWithSingleQuotes = `
347+
TEST_VAR=\${{environment.ENV_VARIABLE}}
348+
ANOTHER_TEST=\${{environment.ANOTHER_VAR}}
349+
SIMPLE=\${{environment.SIMPLE_VAR}}
350+
`;
351+
352+
const resolved = prepareEnvironmentVariables(
353+
serviceWithSingleQuotes,
354+
"",
355+
envWithSingleQuotes,
356+
);
357+
358+
expect(resolved).toEqual([
359+
"TEST_VAR=ENVITONME'NT",
360+
"ANOTHER_TEST=value with 'quotes' inside",
361+
"SIMPLE=no-quotes",
362+
]);
363+
});
364+
});
365+
366+
describe("prepareEnvironmentVariablesForShell (shell escaping)", () => {
367+
it("escapes single quotes in environment variable values", () => {
368+
const serviceEnv = `
369+
ENV_VARIABLE='ENVITONME'NT'
370+
ANOTHER_VAR='value with 'quotes' inside'
371+
`;
372+
373+
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
374+
375+
// shell-quote should wrap these in double quotes
376+
expect(resolved).toEqual([
377+
`"ENV_VARIABLE=ENVITONME'NT"`,
378+
`"ANOTHER_VAR=value with 'quotes' inside"`,
379+
]);
380+
});
381+
382+
it("escapes double quotes in environment variable values", () => {
383+
const serviceEnv = `
384+
MESSAGE="Hello "World""
385+
QUOTED_PATH="/path/to/"file""
386+
`;
387+
388+
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
389+
390+
// shell-quote wraps in single quotes when there are double quotes inside
391+
expect(resolved).toEqual([
392+
`'MESSAGE=Hello "World"'`,
393+
`'QUOTED_PATH=/path/to/"file"'`,
394+
]);
395+
});
396+
397+
it("escapes dollar signs in environment variable values", () => {
398+
const serviceEnv = `
399+
PRICE=$100
400+
VARIABLE=$HOME/path
401+
TEMPLATE=Hello $USER
402+
`;
403+
404+
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
405+
406+
// Dollar signs should be escaped to prevent variable expansion
407+
for (const env of resolved) {
408+
expect(env).toContain("$");
409+
}
410+
});
411+
412+
it("escapes backticks in environment variable values", () => {
413+
const serviceEnv = `
414+
COMMAND=\`echo "test"\`
415+
NESTED=value with \`backticks\` inside
416+
`;
417+
418+
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
419+
420+
// Backticks are escaped/removed by dotenv parsing, but values should be safely quoted
421+
expect(resolved.length).toBe(2);
422+
expect(resolved[0]).toContain("COMMAND");
423+
expect(resolved[1]).toContain("NESTED");
424+
});
425+
426+
it("handles environment variables with spaces", () => {
427+
const serviceEnv = `
428+
FULL_NAME="John Doe"
429+
MESSAGE='Hello World'
430+
SENTENCE=This is a test
431+
`;
432+
433+
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
434+
435+
// shell-quote uses single quotes for strings with spaces
436+
expect(resolved).toEqual([
437+
`'FULL_NAME=John Doe'`,
438+
`'MESSAGE=Hello World'`,
439+
`'SENTENCE=This is a test'`,
440+
]);
441+
});
442+
443+
it("handles environment variables with backslashes", () => {
444+
const serviceEnv = `
445+
WINDOWS_PATH=C:\\Users\\Documents
446+
ESCAPED=value\\with\\backslashes
447+
`;
448+
449+
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
450+
451+
// Backslashes should be properly escaped
452+
expect(resolved.length).toBe(2);
453+
for (const env of resolved) {
454+
expect(env).toContain("\\");
455+
}
456+
});
457+
458+
it("handles simple environment variables without special characters", () => {
459+
const serviceEnv = `
460+
NODE_ENV=production
461+
PORT=3000
462+
DEBUG=true
463+
`;
464+
465+
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
466+
467+
// shell-quote escapes the = sign in some cases
468+
expect(resolved).toEqual([
469+
"NODE_ENV\\=production",
470+
"PORT\\=3000",
471+
"DEBUG\\=true",
472+
]);
473+
});
474+
475+
it("handles environment variables with mixed special characters", () => {
476+
const serviceEnv = `
477+
COMPLEX='value with "double" and 'single' quotes'
478+
BASH_COMMAND=echo "$HOME" && echo 'test'
479+
WEIRD=\`echo "$VAR"\` with 'quotes' and "more"
480+
`;
481+
482+
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
483+
484+
// All should be escaped, none should throw errors
485+
expect(resolved.length).toBe(3);
486+
// Verify each can be safely used in shell
487+
for (const env of resolved) {
488+
expect(typeof env).toBe("string");
489+
expect(env.length).toBeGreaterThan(0);
490+
}
491+
});
492+
493+
it("handles environment variables with newlines", () => {
494+
const serviceEnv = `
495+
MULTILINE="line1
496+
line2
497+
line3"
498+
`;
499+
500+
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
501+
502+
expect(resolved.length).toBe(1);
503+
expect(resolved[0]).toContain("MULTILINE");
504+
});
505+
506+
it("handles empty environment variable values", () => {
507+
const serviceEnv = `
508+
EMPTY=
509+
EMPTY_QUOTED=""
510+
EMPTY_SINGLE=''
511+
`;
512+
513+
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
514+
515+
// shell-quote escapes the = sign for empty values
516+
expect(resolved).toEqual([
517+
"EMPTY\\=",
518+
"EMPTY_QUOTED\\=",
519+
"EMPTY_SINGLE\\=",
520+
]);
521+
});
522+
523+
it("handles environment variables with equals signs in values", () => {
524+
const serviceEnv = `
525+
EQUATION=a=b+c
526+
CONNECTION_STRING=user=admin;password=test
527+
`;
528+
529+
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
530+
531+
expect(resolved.length).toBe(2);
532+
expect(resolved[0]).toContain("EQUATION");
533+
expect(resolved[1]).toContain("CONNECTION_STRING");
534+
});
535+
536+
it("resolves and escapes environment variables together", () => {
537+
const projectEnv = `
538+
BASE_URL=https://example.com
539+
API_KEY='secret-key-with-quotes'
540+
`;
541+
542+
const environmentEnv = `
543+
ENV_NAME=production
544+
DB_PASS='pa$$word'
545+
`;
546+
547+
const serviceEnv = `
548+
FULL_URL=\${{project.BASE_URL}}/api
549+
AUTH_KEY=\${{project.API_KEY}}
550+
ENVIRONMENT=\${{environment.ENV_NAME}}
551+
DB_PASSWORD=\${{environment.DB_PASS}}
552+
CUSTOM='value with 'quotes' inside'
553+
`;
554+
555+
const resolved = prepareEnvironmentVariablesForShell(
556+
serviceEnv,
557+
projectEnv,
558+
environmentEnv,
559+
);
560+
561+
expect(resolved.length).toBe(5);
562+
// All resolved values should be properly escaped
563+
for (const env of resolved) {
564+
expect(typeof env).toBe("string");
565+
}
566+
});
567+
568+
it("handles environment variables with semicolons and ampersands", () => {
569+
const serviceEnv = `
570+
COMMAND=echo "test" && echo "test2"
571+
MULTIPLE=cmd1; cmd2; cmd3
572+
URL_WITH_PARAMS=https://example.com?a=1&b=2&c=3
573+
`;
574+
575+
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
576+
577+
expect(resolved.length).toBe(3);
578+
// These should be safely escaped to prevent command injection
579+
for (const env of resolved) {
580+
expect(typeof env).toBe("string");
581+
expect(env.length).toBeGreaterThan(0);
582+
}
583+
});
584+
585+
it("handles environment variables with pipes and redirects", () => {
586+
const serviceEnv = `
587+
PIPE_COMMAND=cat file | grep test
588+
REDIRECT=echo "test" > output.txt
589+
BOTH=cat input.txt | grep pattern > output.txt
590+
`;
591+
592+
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
593+
594+
expect(resolved.length).toBe(3);
595+
// Pipes and redirects should be safely quoted
596+
expect(resolved[0]).toContain("PIPE_COMMAND");
597+
expect(resolved[1]).toContain("REDIRECT");
598+
expect(resolved[2]).toContain("BOTH");
599+
// At least one should contain a pipe
600+
const hasPipe = resolved.some((env) => env.includes("|"));
601+
expect(hasPipe).toBe(true);
602+
});
603+
604+
it("handles environment variables with parentheses and brackets", () => {
605+
const serviceEnv = `
606+
MATH=(a+b)*c
607+
ARRAY=[1,2,3]
608+
JSON={"key":"value"}
609+
`;
610+
611+
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
612+
613+
expect(resolved.length).toBe(3);
614+
expect(resolved[0]).toContain("(");
615+
expect(resolved[1]).toContain("[");
616+
expect(resolved[2]).toContain("{");
617+
});
618+
619+
it("handles very long environment variable values", () => {
620+
const longValue = "a".repeat(10000);
621+
const serviceEnv = `LONG_VAR=${longValue}`;
622+
623+
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
624+
625+
expect(resolved.length).toBe(1);
626+
expect(resolved[0]).toContain("LONG_VAR");
627+
expect(resolved[0]?.length).toBeGreaterThan(10000);
628+
});
629+
630+
it("handles special unicode characters in environment variables", () => {
631+
const serviceEnv = `
632+
EMOJI=Hello 🌍 World 🚀
633+
CHINESE=你好世界
634+
SPECIAL=café résumé naïve
635+
`;
636+
637+
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
638+
639+
expect(resolved.length).toBe(3);
640+
expect(resolved[0]).toContain("🌍");
641+
expect(resolved[1]).toContain("你好");
642+
expect(resolved[2]).toContain("café");
643+
});
335644
});

packages/server/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
"react": "18.2.0",
7676
"react-dom": "18.2.0",
7777
"rotating-file-stream": "3.2.3",
78+
"shell-quote": "^1.8.1",
7879
"slugify": "^1.6.6",
7980
"ssh2": "1.15.0",
8081
"toml": "3.0.0",
@@ -93,6 +94,7 @@
9394
"@types/qrcode": "^1.5.5",
9495
"@types/react": "^18.3.5",
9596
"@types/react-dom": "^18.3.0",
97+
"@types/shell-quote": "^1.7.5",
9698
"@types/ssh2": "1.15.1",
9799
"@types/ws": "8.5.10",
98100
"drizzle-kit": "^0.30.6",

packages/server/src/utils/builders/compose.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { dirname, join } from "node:path";
22
import { paths } from "@dokploy/server/constants";
33
import type { InferResultType } from "@dokploy/server/types/with";
44
import boxen from "boxen";
5+
import { quote } from "shell-quote";
56
import { writeDomainsToCompose } from "../docker/domain";
67
import {
78
encodeBase64,
@@ -137,7 +138,7 @@ const getExportEnvCommand = (compose: ComposeNested) => {
137138
compose.environment.project.env,
138139
);
139140
const exports = Object.entries(envVars)
140-
.map(([key, value]) => `export ${key}=${JSON.stringify(value)}`)
141+
.map(([key, value]) => `export ${key}=${quote([value])}`)
141142
.join("\n");
142143

143144
return exports ? `\n# Export environment variables\n${exports}\n` : "";

packages/server/src/utils/builders/docker-file.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import {
22
getEnviromentVariablesObject,
3-
prepareEnvironmentVariables,
3+
prepareEnvironmentVariablesForShell,
44
} from "@dokploy/server/utils/docker/utils";
5+
import { quote } from "shell-quote";
56
import {
67
getBuildAppDirectory,
78
getDockerContextPath,
@@ -40,14 +41,14 @@ export const getDockerCommand = (application: ApplicationNested) => {
4041
commandArgs.push("--no-cache");
4142
}
4243

43-
const args = prepareEnvironmentVariables(
44+
const args = prepareEnvironmentVariablesForShell(
4445
buildArgs,
4546
application.environment.project.env,
4647
application.environment.env,
4748
);
4849

4950
for (const arg of args) {
50-
commandArgs.push("--build-arg", `'${arg}'`);
51+
commandArgs.push("--build-arg", arg);
5152
}
5253

5354
const secrets = getEnviromentVariablesObject(
@@ -57,7 +58,7 @@ export const getDockerCommand = (application: ApplicationNested) => {
5758
);
5859

5960
const joinedSecrets = Object.entries(secrets)
60-
.map(([key, value]) => `${key}='${value.replace(/'/g, "'\"'\"'")}'`)
61+
.map(([key, value]) => `${key}=${quote([value])}`)
6162
.join(" ");
6263

6364
for (const key in secrets) {

0 commit comments

Comments
 (0)