From ff39cb51e6b1af7a645be0fea9717ffeb4382d93 Mon Sep 17 00:00:00 2001 From: Narciso Date: Mon, 3 Aug 2026 17:57:02 -0400 Subject: [PATCH] fix: escape env values written to the Compose .env file so $, #, and quotes survive literally --- .../compose/env-file-literals.test.ts | 96 +++++++++++++++++++ .../env-file-literals-dockerfile.test.ts | 43 +++++++++ packages/server/src/utils/builders/compose.ts | 4 +- packages/server/src/utils/docker/utils.ts | 21 ++++ 4 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 apps/dokploy/__test__/compose/env-file-literals.test.ts create mode 100644 apps/dokploy/__test__/deploy/env-file-literals-dockerfile.test.ts diff --git a/apps/dokploy/__test__/compose/env-file-literals.test.ts b/apps/dokploy/__test__/compose/env-file-literals.test.ts new file mode 100644 index 000000000..2dbb7fa4e --- /dev/null +++ b/apps/dokploy/__test__/compose/env-file-literals.test.ts @@ -0,0 +1,96 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { getCreateEnvFileCommand } from "@dokploy/server/utils/builders/compose"; +import { afterEach, describe, expect, it } from "vitest"; + +// Regression coverage for https://github.com/Dokploy/dokploy/issues/4694 — +// values must survive Docker Compose's own `.env` parsing, not just base64 decode. +const appName = `env-file-literals-${process.pid}`; +const projectPath = join(process.cwd(), ".docker", "compose", appName); +const codePath = join(projectPath, "code"); + +afterEach(() => { + try { + execFileSync("docker", ["compose", "down", "--remove-orphans"], { + cwd: codePath, + stdio: "ignore", + }); + } catch { + // Project may not have been created (e.g. an earlier assertion failed). + } + rmSync(projectPath, { force: true, recursive: true }); +}); + +const cases: Record = { + PASSWORD: "pa$$word", + SPECIAL: '!"#$%&/()=?', + NESTED_JSON: '{"nested":{"a":1}}', + MAIL_PASSWORD: "abc#de", + TRAILING_BACKSLASH: "trailing\\", + QUOTE_INSIDE: 'she said "hi"', + APOSTROPHE: "it's a test", + UNICODE: "héllo wörld 日本語 🚀", + MULTILINE_PEM: "-----BEGIN KEY-----\nabc123\n-----END KEY-----", +}; + +// How each value must be typed in the UI so the (unchanged) dotenv input +// parser resolves it to the raw string in `cases` above. +const inputEncoding: Record = { + PASSWORD: "pa$$word", + SPECIAL: `'!"#$%&/()=?'`, + NESTED_JSON: '{"nested":{"a":1}}', + MAIL_PASSWORD: `"abc#de"`, + TRAILING_BACKSLASH: "trailing\\", + QUOTE_INSIDE: 'she said "hi"', + APOSTROPHE: "it's a test", + UNICODE: "héllo wörld 日本語 🚀", + MULTILINE_PEM: '"-----BEGIN KEY-----\nabc123\n-----END KEY-----"', +}; + +describe("getCreateEnvFileCommand", () => { + it("writes special environment values that Docker Compose reads back literally", () => { + mkdirSync(codePath, { recursive: true }); + + const serviceEnv = Object.entries(inputEncoding) + .map(([key, value]) => `${key}=${value}`) + .join("\n"); + + const command = getCreateEnvFileCommand({ + appName, + composePath: "docker-compose.yml", + env: serviceEnv, + randomize: false, + suffix: "", + serverId: null, + environment: { project: { env: "" }, env: "" }, + } as Parameters[0]); + + execFileSync("bash", ["-c", command]); + + const composeFile = `services:\n test:\n image: busybox\n environment:\n${Object.keys( + cases, + ) + .map((key) => ` - ${key}=\${${key}}`) + .join("\n")}\n`; + writeFileSync(join(codePath, "docker-compose.yml"), composeFile); + + const dumpScript = `for k in ${Object.keys(cases).join(" ")}; do printf '%s\\0' "$k"; eval "printf '%s\\0' \\"\\$$k\\""; done`; + + const out = execFileSync( + "docker", + ["compose", "run", "--rm", "-T", "test", "sh", "-c", dumpScript], + { cwd: codePath, encoding: "utf8" }, + ); + + const parts = out.split("\0"); + const actual: Record = {}; + for (let i = 0; i < parts.length - 1; i += 2) { + actual[parts[i] as string] = parts[i + 1] as string; + } + + for (const [key, value] of Object.entries(cases)) { + expect(actual[key], key).toBe(value); + } + }, 60000); +}); diff --git a/apps/dokploy/__test__/deploy/env-file-literals-dockerfile.test.ts b/apps/dokploy/__test__/deploy/env-file-literals-dockerfile.test.ts new file mode 100644 index 000000000..4ce0f5ded --- /dev/null +++ b/apps/dokploy/__test__/deploy/env-file-literals-dockerfile.test.ts @@ -0,0 +1,43 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { createEnvFileCommand } from "@dokploy/server/utils/builders/utils"; +import { parse } from "dotenv"; +import { afterEach, describe, expect, it } from "vitest"; + +// Unlike compose's .env, this one is read by the app's own build tooling +// (generic dotenv, e.g. Next.js/Vite) — must stay unquoted, not Compose-escaped. +const appName = `env-file-literals-dockerfile-${process.pid}`; +const projectPath = join(process.cwd(), ".docker", "compose", appName); +const codePath = join(projectPath, "code"); +const dockerFilePath = join(codePath, "Dockerfile"); + +afterEach(() => rmSync(projectPath, { force: true, recursive: true })); + +const cases: Record = { + PASSWORD: "pa$$word", + NESTED_JSON: '{"nested":{"a":1}}', + QUOTE_INSIDE: 'she said "hi"', + BACKSLASH: "back\\slash", + UNICODE: "héllo wörld 日本語 🚀", +}; + +describe("createEnvFileCommand", () => { + it("writes special environment values that a generic dotenv parser reads back literally", () => { + mkdirSync(codePath, { recursive: true }); + + const serviceEnv = Object.entries(cases) + .map(([key, value]) => `${key}=${value}`) + .join("\n"); + + const command = createEnvFileCommand(dockerFilePath, serviceEnv, "", ""); + execFileSync("bash", ["-c", command]); + + const written = readFileSync(join(codePath, ".env"), "utf8"); + const parsed = parse(written); + + for (const [key, value] of Object.entries(cases)) { + expect(parsed[key], key).toBe(value); + } + }); +}); diff --git a/packages/server/src/utils/builders/compose.ts b/packages/server/src/utils/builders/compose.ts index e5643074e..1694a48ed 100644 --- a/packages/server/src/utils/builders/compose.ts +++ b/packages/server/src/utils/builders/compose.ts @@ -7,7 +7,7 @@ import { writeDomainsToCompose } from "../docker/domain"; import { encodeBase64, getEnvironmentVariablesObject, - prepareEnvironmentVariables, + prepareEnvironmentVariablesForFile, } from "../docker/utils"; export type ComposeNested = InferResultType< @@ -127,7 +127,7 @@ export const getCreateEnvFileCommand = (compose: ComposeNested) => { envContent += `\nCOMPOSE_PREFIX=${compose.suffix}`; } - const envFileContent = prepareEnvironmentVariables( + const envFileContent = prepareEnvironmentVariablesForFile( envContent, compose.environment.project.env, compose.environment.env, diff --git a/packages/server/src/utils/docker/utils.ts b/packages/server/src/utils/docker/utils.ts index 6e9adb5d5..4d632d7e7 100644 --- a/packages/server/src/utils/docker/utils.ts +++ b/packages/server/src/utils/docker/utils.ts @@ -465,6 +465,27 @@ export const prepareEnvironmentVariablesForShell = ( return envVars.map((env) => quote([env])); }; +export const prepareEnvironmentVariablesForFile = ( + serviceEnv: string | null, + projectEnv?: string | null, + environmentEnv?: string | null, +): string[] => { + const envVars = prepareEnvironmentVariables( + serviceEnv, + projectEnv, + environmentEnv, + ); + + return envVars.map((pair) => { + const [key, value] = parseEnvironmentKeyValuePair(pair); + const escapedValue = value + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + .replace(/\$/g, "\\$"); + return `${key}="${escapedValue}"`; + }); +}; + export const parseEnvironmentKeyValuePair = ( pair: string, ): [string, string] => {