feat: enhance environment variable handling for shell commands

- Added `prepareEnvironmentVariablesForShell` function to properly escape environment variables for shell usage.
- Updated various builders (Docker, Heroku, Nixpacks, Paketo, Railpack) to utilize the new function for improved handling of special characters in environment variables.
- Introduced tests to validate the handling of environment variables with various special characters, ensuring robustness in shell command execution.
- Added `shell-quote` dependency to manage quoting of shell arguments effectively.
This commit is contained in:
Mauricio Siu
2025-11-19 21:17:09 -06:00
parent 42a4cc7fff
commit af2b053caa
10 changed files with 368 additions and 19 deletions

View File

@@ -2,6 +2,7 @@ import { dirname, join } from "node:path";
import { paths } from "@dokploy/server/constants";
import type { InferResultType } from "@dokploy/server/types/with";
import boxen from "boxen";
import { quote } from "shell-quote";
import { writeDomainsToCompose } from "../docker/domain";
import {
encodeBase64,
@@ -137,7 +138,7 @@ const getExportEnvCommand = (compose: ComposeNested) => {
compose.environment.project.env,
);
const exports = Object.entries(envVars)
.map(([key, value]) => `export ${key}=${JSON.stringify(value)}`)
.map(([key, value]) => `export ${key}=${quote([value])}`)
.join("\n");
return exports ? `\n# Export environment variables\n${exports}\n` : "";

View File

@@ -1,7 +1,8 @@
import {
getEnviromentVariablesObject,
prepareEnvironmentVariables,
prepareEnvironmentVariablesForShell,
} from "@dokploy/server/utils/docker/utils";
import { quote } from "shell-quote";
import {
getBuildAppDirectory,
getDockerContextPath,
@@ -40,14 +41,14 @@ export const getDockerCommand = (application: ApplicationNested) => {
commandArgs.push("--no-cache");
}
const args = prepareEnvironmentVariables(
const args = prepareEnvironmentVariablesForShell(
buildArgs,
application.environment.project.env,
application.environment.env,
);
for (const arg of args) {
commandArgs.push("--build-arg", `'${arg}'`);
commandArgs.push("--build-arg", arg);
}
const secrets = getEnviromentVariablesObject(
@@ -57,7 +58,7 @@ export const getDockerCommand = (application: ApplicationNested) => {
);
const joinedSecrets = Object.entries(secrets)
.map(([key, value]) => `${key}='${value.replace(/'/g, "'\"'\"'")}'`)
.map(([key, value]) => `${key}=${quote([value])}`)
.join(" ");
for (const key in secrets) {

View File

@@ -1,4 +1,4 @@
import { prepareEnvironmentVariables } from "../docker/utils";
import { prepareEnvironmentVariablesForShell } from "../docker/utils";
import { getBuildAppDirectory } from "../filesystem/directory";
import type { ApplicationNested } from ".";
@@ -6,7 +6,7 @@ export const getHerokuCommand = (application: ApplicationNested) => {
const { env, appName, cleanCache } = application;
const buildAppDirectory = getBuildAppDirectory(application);
const envVariables = prepareEnvironmentVariables(
const envVariables = prepareEnvironmentVariablesForShell(
env,
application.environment.project.env,
application.environment.env,
@@ -26,7 +26,7 @@ export const getHerokuCommand = (application: ApplicationNested) => {
}
for (const env of envVariables) {
args.push("--env", `'${env}'`);
args.push("--env", env);
}
const command = `pack ${args.join(" ")}`;

View File

@@ -1,7 +1,7 @@
import path from "node:path";
import { getStaticCommand } from "@dokploy/server/utils/builders/static";
import { nanoid } from "nanoid";
import { prepareEnvironmentVariables } from "../docker/utils";
import { prepareEnvironmentVariablesForShell } from "../docker/utils";
import { getBuildAppDirectory } from "../filesystem/directory";
import type { ApplicationNested } from ".";
@@ -10,7 +10,7 @@ export const getNixpacksCommand = (application: ApplicationNested) => {
const buildAppDirectory = getBuildAppDirectory(application);
const buildContainerId = `${appName}-${nanoid(10)}`;
const envVariables = prepareEnvironmentVariables(
const envVariables = prepareEnvironmentVariablesForShell(
env,
application.environment.project.env,
application.environment.env,
@@ -23,7 +23,7 @@ export const getNixpacksCommand = (application: ApplicationNested) => {
}
for (const env of envVariables) {
args.push("--env", `'${env}'`);
args.push("--env", env);
}
if (publishDirectory) {

View File

@@ -1,4 +1,4 @@
import { prepareEnvironmentVariables } from "../docker/utils";
import { prepareEnvironmentVariablesForShell } from "../docker/utils";
import { getBuildAppDirectory } from "../filesystem/directory";
import type { ApplicationNested } from ".";
@@ -6,7 +6,7 @@ export const getPaketoCommand = (application: ApplicationNested) => {
const { env, appName, cleanCache } = application;
const buildAppDirectory = getBuildAppDirectory(application);
const envVariables = prepareEnvironmentVariables(
const envVariables = prepareEnvironmentVariablesForShell(
env,
application.environment.project.env,
application.environment.env,
@@ -26,7 +26,7 @@ export const getPaketoCommand = (application: ApplicationNested) => {
}
for (const env of envVariables) {
args.push("--env", `'${env}'`);
args.push("--env", env);
}
const command = `pack ${args.join(" ")}`;

View File

@@ -1,8 +1,10 @@
import { createHash } from "node:crypto";
import { nanoid } from "nanoid";
import { quote } from "shell-quote";
import {
parseEnvironmentKeyValuePair,
prepareEnvironmentVariables,
prepareEnvironmentVariablesForShell,
} from "../docker/utils";
import { getBuildAppDirectory } from "../filesystem/directory";
import type { ApplicationNested } from ".";
@@ -18,7 +20,7 @@ const calculateSecretsHash = (envVariables: string[]): string => {
export const getRailpackCommand = (application: ApplicationNested) => {
const { env, appName, cleanCache } = application;
const buildAppDirectory = getBuildAppDirectory(application);
const envVariables = prepareEnvironmentVariables(
const envVariables = prepareEnvironmentVariablesForShell(
env,
application.environment.project.env,
application.environment.env,
@@ -35,7 +37,7 @@ export const getRailpackCommand = (application: ApplicationNested) => {
];
for (const env of envVariables) {
prepareArgs.push("--env", `'${env}'`);
prepareArgs.push("--env", env);
}
// Calculate secrets hash for layer invalidation
@@ -63,12 +65,19 @@ export const getRailpackCommand = (application: ApplicationNested) => {
];
// Add secrets properly formatted
// Use prepareEnvironmentVariables (without ForShell) to get raw values for parsing
const rawEnvVariables = prepareEnvironmentVariables(
env,
application.environment.project.env,
application.environment.env,
);
const exportEnvs = [];
for (const pair of envVariables) {
for (const pair of rawEnvVariables) {
const [key, value] = parseEnvironmentKeyValuePair(pair);
if (key && value) {
buildArgs.push("--secret", `id=${key},env=${key}`);
exportEnvs.push(`export ${key}='${value}'`);
// Use shell-quote to properly escape the export statement
exportEnvs.push(`export ${key}=${quote([value])}`);
}
}

View File

@@ -5,6 +5,7 @@ import { docker, paths } from "@dokploy/server/constants";
import type { Compose } from "@dokploy/server/services/compose";
import type { ContainerInfo, ResourceRequirements } from "dockerode";
import { parse } from "dotenv";
import { quote } from "shell-quote";
import type { ApplicationNested } from "../builders";
import type { MariadbNested } from "../databases/mariadb";
import type { MongoNested } from "../databases/mongo";
@@ -310,6 +311,21 @@ export const prepareEnvironmentVariables = (
return resolvedVars;
};
export const prepareEnvironmentVariablesForShell = (
serviceEnv: string | null,
projectEnv?: string | null,
environmentEnv?: string | null,
): string[] => {
const envVars = prepareEnvironmentVariables(
serviceEnv,
projectEnv,
environmentEnv,
);
// Using shell-quote library to properly escape shell arguments
// This is the standard way to handle special characters in shell commands
return envVars.map((env) => quote([env]));
};
export const parseEnvironmentKeyValuePair = (
pair: string,
): [string, string] => {