Skip to content
Open
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
8 changes: 3 additions & 5 deletions apps/dokploy/lib/password-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,9 @@ export const generateRandomPassword = (
typeof globalThis !== "undefined" ? globalThis.crypto : undefined;

if (!cryptoApi?.getRandomValues) {
let fallback = "";
for (let i = 0; i < safeLength; i += 1) {
fallback += charset[Math.floor(Math.random() * charset.length)];
}
return fallback;
throw new Error(
"crypto.getRandomValues is not available. Secure random password generation requires a cryptographically secure random number generator.",
);
}

const values = new Uint32Array(safeLength);
Expand Down
2 changes: 1 addition & 1 deletion apps/monitoring/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ require (
github.com/gofiber/fiber/v2 v2.52.6
github.com/joho/godotenv v1.5.1
github.com/mattn/go-sqlite3 v1.14.24
github.com/robfig/cron/v3 v3.0.1
github.com/shirou/gopsutil/v3 v3.24.5
)

Expand All @@ -20,7 +21,6 @@ require (
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/tklauser/go-sysconf v0.3.14 // indirect
github.com/tklauser/numcpus v0.8.0 // indirect
Expand Down
2 changes: 0 additions & 2 deletions apps/monitoring/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,6 @@ github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVS
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/zcalusic/sysinfo v1.1.3 h1:u/AVENkuoikKuIZ4sUEJ6iibpmQP6YpGD8SSMCrqAF0=
github.com/zcalusic/sysinfo v1.1.3/go.mod h1:NX+qYnWGtJVPV0yWldff9uppNKU4h40hJIRPf/pGLv4=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
Expand Down
4 changes: 3 additions & 1 deletion apps/monitoring/middleware/auth.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package middleware

import (
"crypto/subtle"
"strings"

"github.com/gofiber/fiber/v2"
Expand Down Expand Up @@ -28,7 +29,8 @@ func AuthMiddleware() fiber.Handler {
// Extract the token
token := strings.TrimPrefix(authHeader, "Bearer ")

if token != expectedToken {
// Use constant-time comparison to prevent timing attacks
if subtle.ConstantTimeCompare([]byte(token), []byte(expectedToken)) != 1 {
return c.Status(401).JSON(fiber.Map{
"error": "Invalid token",
})
Expand Down
14 changes: 6 additions & 8 deletions packages/server/src/db/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,12 @@ if (DATABASE_URL) {
password,
)}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}`;
} else {
console.warn(`
⚠️ [DEPRECATED DATABASE CONFIG]
You are using the legacy hardcoded database credentials.
This mode WILL BE REMOVED in a future release.
throw new Error(`
❌ [DATABASE CONFIGURATION REQUIRED]
No database credentials configured. You must set one of:
- DATABASE_URL environment variable, or
- POSTGRES_PASSWORD_FILE environment variable (recommended for Docker Secrets)

Please migrate to Docker Secrets using POSTGRES_PASSWORD_FILE.
Please execute this command in your server: curl -sSL https://dokploy.com/security/0.26.6.sh | bash
For migration instructions, visit: https://dokploy.com/security/database-config
`);
dbUrl =
"postgres://dokploy:amukds4wi9001583845717ad2@dokploy-postgres:5432/dokploy";
}
97 changes: 87 additions & 10 deletions packages/server/src/utils/schedules/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,44 @@ import {
} from "@dokploy/server/services/deployment";
import { findScheduleById } from "@dokploy/server/services/schedule";
import { scheduledJobs, scheduleJob as scheduleJobNode } from "node-schedule";
import { quote } from "shell-quote";
import { getComposeContainer, getServiceContainer } from "../docker/utils";
import { execAsyncRemote } from "../process/execAsync";
import { spawnAsync } from "../process/spawnAsync";

// Allowlist of permitted shell types
const ALLOWED_SHELL_TYPES = ["sh", "bash", "ash", "dash"];

/**
* Validates that the shell type is in the allowlist
*/
function validateShellType(shellType: string): void {
if (!ALLOWED_SHELL_TYPES.includes(shellType)) {
throw new Error(
`Invalid shell type: ${shellType}. Allowed types: ${ALLOWED_SHELL_TYPES.join(", ")}`,
);
}
}

/**
* Validates that the container ID is a valid Docker container ID format
* Docker container IDs are 64-character hexadecimal strings, but shortened versions (12 chars) are also common
*/
function validateContainerId(containerId: string): void {
if (!containerId || !/^[a-f0-9]{12,64}$/i.test(containerId)) {
throw new Error(`Invalid container ID: ${containerId}`);
}
}

/**
* Validates that the command is not empty
*/
function validateCommand(command: string): void {
if (!command || command.trim().length === 0) {
throw new Error("Command cannot be empty");
}
}

export const scheduleJob = (schedule: Schedule) => {
const { cronExpression, scheduleId, timezone } = schedule;

Expand Down Expand Up @@ -71,18 +105,30 @@ export const runCommand = async (scheduleId: string) => {
serverId = compose.serverId || "";
}

// Validate inputs to prevent command injection
validateContainerId(containerId);
validateShellType(shellType);
validateCommand(command);

// Use shell-quote to safely escape the command
const escapedCommand = quote([command]);

if (serverId) {
try {
// Use shell-quote to safely escape all parameters including containerId and shellType
const escapedContainerId = quote([containerId]);
const escapedShellType = quote([shellType]);
const escapedLogPath = quote([deployment.logPath]);
await execAsyncRemote(
serverId,
`
set -e
echo "Running command: docker exec ${containerId} ${shellType} -c '${command}'" >> ${deployment.logPath};
docker exec ${containerId} ${shellType} -c '${command}' >> ${deployment.logPath} 2>> ${deployment.logPath} || {
echo "❌ Command failed" >> ${deployment.logPath};
echo "Running command: docker exec ${escapedContainerId} ${escapedShellType} -c ${escapedCommand}" >> ${escapedLogPath};
docker exec ${escapedContainerId} ${escapedShellType} -c ${escapedCommand} >> ${escapedLogPath} 2>> ${escapedLogPath} || {
echo "❌ Command failed" >> ${escapedLogPath};
exit 1;
}
echo "✅ Command executed successfully" >> ${deployment.logPath};
echo "✅ Command executed successfully" >> ${escapedLogPath};
`,
);
} catch (error) {
Expand All @@ -93,9 +139,15 @@ export const runCommand = async (scheduleId: string) => {
const writeStream = createWriteStream(deployment.logPath, { flags: "a" });

try {
// Use escaped values for logging
const escapedContainerId = quote([containerId]);
const escapedShellType = quote([shellType]);
writeStream.write(
`docker exec ${containerId} ${shellType} -c ${command}\n`,
`docker exec ${escapedContainerId} ${escapedShellType} -c ${escapedCommand}\n`,
);
// spawnAsync uses an array of arguments, which is inherently safe from shell injection
// Each argument is passed separately to the process, not through shell interpolation
// The original unescaped command is safe here because it's not passed through a shell
await spawnAsync(
"docker",
["exec", containerId, shellType, "-c", command],
Expand Down Expand Up @@ -123,9 +175,19 @@ export const runCommand = async (scheduleId: string) => {
const { SCHEDULES_PATH } = paths();
const fullPath = path.join(SCHEDULES_PATH, appName || "");

// Validate that the script exists within the expected directory
const scriptPath = path.join(fullPath, "script.sh");
const fs = await import("node:fs/promises");
try {
await fs.access(scriptPath);
} catch {
throw new Error(`Script not found at expected location: ${scriptPath}`);
}

// Use absolute path to avoid path traversal
await spawnAsync(
"bash",
["-c", "./script.sh"],
[scriptPath],
async (data) => {
if (writeStream.writable) {
// we need to extract the PID and Schedule ID from the data
Expand All @@ -151,14 +213,29 @@ export const runCommand = async (scheduleId: string) => {
try {
const { SCHEDULES_PATH } = paths(true);
const fullPath = path.join(SCHEDULES_PATH, appName || "");

// Validate that the script exists within the expected directory
const scriptPath = path.join(fullPath, "script.sh");

// Note: For remote servers, we cannot validate file existence locally
// The validation will happen at execution time on the remote server
// Ensure path is properly escaped to prevent injection

// Use shell-quote to escape all parameters
const escapedLogPath = quote([deployment.logPath]);
const escapedScriptPath = quote([scriptPath]);
const command = `
set -e
echo "Running script" >> ${deployment.logPath};
bash -c ${fullPath}/script.sh 2>&1 | tee -a ${deployment.logPath} || {
echo "❌ Command failed" >> ${deployment.logPath};
if [ ! -f ${escapedScriptPath} ]; then
echo "❌ Script not found at ${escapedScriptPath}" >> ${escapedLogPath};
exit 1;
fi
echo "Running script" >> ${escapedLogPath};
bash ${escapedScriptPath} 2>&1 | tee -a ${escapedLogPath} || {
echo "❌ Command failed" >> ${escapedLogPath};
exit 1;
}
echo "✅ Command executed successfully" >> ${deployment.logPath};
echo "✅ Command executed successfully" >> ${escapedLogPath};
`;
await execAsyncRemote(serverId, command, async (data) => {
// we need to extract the PID and Schedule ID from the data
Expand Down