From eeb6e7b8ea88e4b4b1fac8460755464100516ac9 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Mon, 20 Jul 2026 16:11:16 -0600 Subject: [PATCH] fix(security): escape S3/rclone args and restore paths to prevent command injection Wrap S3 credential flags (getS3Credentials + destination.testConnection), the listBackupFiles search path, and every restore backupPath/backupFile with shell-quote's quote() so $(), backticks, quotes and spaces in destination fields or backupFile can no longer break out of the rclone shell commands. --- apps/dokploy/server/api/routers/backup.ts | 3 ++- apps/dokploy/server/api/routers/destination.ts | 13 +++++++------ packages/server/src/utils/backups/utils.ts | 10 +++++----- packages/server/src/utils/restore/compose.ts | 5 +++-- packages/server/src/utils/restore/libsql.ts | 3 ++- packages/server/src/utils/restore/mariadb.ts | 3 ++- packages/server/src/utils/restore/mongo.ts | 3 ++- packages/server/src/utils/restore/mysql.ts | 3 ++- packages/server/src/utils/restore/postgres.ts | 3 ++- packages/server/src/utils/restore/utils.ts | 4 ++-- packages/server/src/utils/restore/web-server.ts | 7 +++++-- 11 files changed, 34 insertions(+), 23 deletions(-) diff --git a/apps/dokploy/server/api/routers/backup.ts b/apps/dokploy/server/api/routers/backup.ts index c324f5cc1..9bc73ce77 100644 --- a/apps/dokploy/server/api/routers/backup.ts +++ b/apps/dokploy/server/api/routers/backup.ts @@ -49,6 +49,7 @@ import { restoreWebServerBackup, } from "@dokploy/server/utils/restore"; import { TRPCError } from "@trpc/server"; +import { quote } from "shell-quote"; import { z } from "zod"; import { createTRPCRouter, @@ -510,7 +511,7 @@ export const backupRouter = createTRPCRouter({ : input.search; const searchPath = baseDir ? `${bucketPath}/${baseDir}` : bucketPath; - const listCommand = `rclone lsjson ${rcloneFlags.join(" ")} "${searchPath}" --no-mimetype --no-modtime 2>/dev/null`; + const listCommand = `rclone lsjson ${rcloneFlags.join(" ")} ${quote([searchPath])} --no-mimetype --no-modtime 2>/dev/null`; let stdout = ""; diff --git a/apps/dokploy/server/api/routers/destination.ts b/apps/dokploy/server/api/routers/destination.ts index cf7395a3f..3d2ad6057 100644 --- a/apps/dokploy/server/api/routers/destination.ts +++ b/apps/dokploy/server/api/routers/destination.ts @@ -10,6 +10,7 @@ import { import { db } from "@dokploy/server/db"; import { TRPCError } from "@trpc/server"; import { desc, eq } from "drizzle-orm"; +import { quote } from "shell-quote"; import { createTRPCRouter, withPermission } from "@/server/api/trpc"; import { audit } from "@/server/api/utils/audit"; import { @@ -58,10 +59,10 @@ export const destinationRouter = createTRPCRouter({ } = input; try { const rcloneFlags = [ - `--s3-access-key-id="${accessKey}"`, - `--s3-secret-access-key="${secretAccessKey}"`, - `--s3-region="${region}"`, - `--s3-endpoint="${endpoint}"`, + `--s3-access-key-id=${quote([accessKey])}`, + `--s3-secret-access-key=${quote([secretAccessKey])}`, + `--s3-region=${quote([region])}`, + `--s3-endpoint=${quote([endpoint])}`, "--s3-no-check-bucket", "--s3-force-path-style", "--retries 1", @@ -70,13 +71,13 @@ export const destinationRouter = createTRPCRouter({ "--contimeout 5s", ]; if (provider) { - rcloneFlags.unshift(`--s3-provider="${provider}"`); + rcloneFlags.unshift(`--s3-provider=${quote([provider])}`); } if (additionalFlags?.length) { rcloneFlags.push(...additionalFlags); } const rcloneDestination = `:s3:${bucket}`; - const rcloneCommand = `rclone ls ${rcloneFlags.join(" ")} "${rcloneDestination}"`; + const rcloneCommand = `rclone ls ${rcloneFlags.join(" ")} ${quote([rcloneDestination])}`; if (IS_CLOUD && !input.serverId) { throw new TRPCError({ diff --git a/packages/server/src/utils/backups/utils.ts b/packages/server/src/utils/backups/utils.ts index ed22067f8..dba843956 100644 --- a/packages/server/src/utils/backups/utils.ts +++ b/packages/server/src/utils/backups/utils.ts @@ -72,16 +72,16 @@ export const getS3Credentials = (destination: Destination) => { const { accessKey, secretAccessKey, region, endpoint, provider } = destination; const rcloneFlags = [ - `--s3-access-key-id="${accessKey}"`, - `--s3-secret-access-key="${secretAccessKey}"`, - `--s3-region="${region}"`, - `--s3-endpoint="${endpoint}"`, + `--s3-access-key-id=${quote([accessKey])}`, + `--s3-secret-access-key=${quote([secretAccessKey])}`, + `--s3-region=${quote([region])}`, + `--s3-endpoint=${quote([endpoint])}`, "--s3-no-check-bucket", "--s3-force-path-style", ]; if (provider) { - rcloneFlags.unshift(`--s3-provider="${provider}"`); + rcloneFlags.unshift(`--s3-provider=${quote([provider])}`); } if (destination.additionalFlags?.length) { diff --git a/packages/server/src/utils/restore/compose.ts b/packages/server/src/utils/restore/compose.ts index 946c94a38..07f23c772 100644 --- a/packages/server/src/utils/restore/compose.ts +++ b/packages/server/src/utils/restore/compose.ts @@ -1,6 +1,7 @@ import type { apiRestoreBackup } from "@dokploy/server/db/schema"; import type { Compose } from "@dokploy/server/services/compose"; import type { Destination } from "@dokploy/server/services/destination"; +import { quote } from "shell-quote"; import type { z } from "zod"; import { getS3Credentials } from "../backups/utils"; import { execAsync, execAsyncRemote } from "../process/execAsync"; @@ -26,10 +27,10 @@ export const restoreComposeBackup = async ( const rcloneFlags = getS3Credentials(destination); const bucketPath = `:s3:${destination.bucket}`; const backupPath = `${bucketPath}/${backupInput.backupFile}`; - let rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`; + let rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} ${quote([backupPath])} | gunzip`; if (backupInput.metadata?.mongo) { - rcloneCommand = `rclone copy ${rcloneFlags.join(" ")} "${backupPath}"`; + rcloneCommand = `rclone copy ${rcloneFlags.join(" ")} ${quote([backupPath])}`; } let credentials: DatabaseCredentials = {}; diff --git a/packages/server/src/utils/restore/libsql.ts b/packages/server/src/utils/restore/libsql.ts index f50f0c6da..9d7c7f15c 100644 --- a/packages/server/src/utils/restore/libsql.ts +++ b/packages/server/src/utils/restore/libsql.ts @@ -1,6 +1,7 @@ import type { apiRestoreBackup } from "@dokploy/server/db/schema"; import type { Destination } from "@dokploy/server/services/destination"; import type { Libsql } from "@dokploy/server/services/libsql"; +import { quote } from "shell-quote"; import type { z } from "zod"; import { getS3Credentials, getServiceContainerCommand } from "../backups/utils"; import { execAsync, execAsyncRemote } from "../process/execAsync"; @@ -19,7 +20,7 @@ export const restoreLibsqlBackup = async ( const backupPath = `${bucketPath}/${backupInput.backupFile}`; - const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}"`; + const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} ${quote([backupPath])}`; const containerSearch = getServiceContainerCommand(appName); const restoreCommand = `docker exec -i $CONTAINER_ID sh -c "tar xzf - -C /var/lib/sqld"`; diff --git a/packages/server/src/utils/restore/mariadb.ts b/packages/server/src/utils/restore/mariadb.ts index afa167280..e4b301f46 100644 --- a/packages/server/src/utils/restore/mariadb.ts +++ b/packages/server/src/utils/restore/mariadb.ts @@ -1,6 +1,7 @@ import type { apiRestoreBackup } from "@dokploy/server/db/schema"; import type { Destination } from "@dokploy/server/services/destination"; import type { Mariadb } from "@dokploy/server/services/mariadb"; +import { quote } from "shell-quote"; import type { z } from "zod"; import { getS3Credentials } from "../backups/utils"; import { execAsync, execAsyncRemote } from "../process/execAsync"; @@ -19,7 +20,7 @@ export const restoreMariadbBackup = async ( const bucketPath = `:s3:${destination.bucket}`; const backupPath = `${bucketPath}/${backupInput.backupFile}`; - const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`; + const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} ${quote([backupPath])} | gunzip`; const command = getRestoreCommand({ appName, diff --git a/packages/server/src/utils/restore/mongo.ts b/packages/server/src/utils/restore/mongo.ts index 91eb9af5a..163340ca8 100644 --- a/packages/server/src/utils/restore/mongo.ts +++ b/packages/server/src/utils/restore/mongo.ts @@ -1,6 +1,7 @@ import type { apiRestoreBackup } from "@dokploy/server/db/schema"; import type { Destination } from "@dokploy/server/services/destination"; import type { Mongo } from "@dokploy/server/services/mongo"; +import { quote } from "shell-quote"; import type { z } from "zod"; import { getS3Credentials } from "../backups/utils"; import { execAsync, execAsyncRemote } from "../process/execAsync"; @@ -18,7 +19,7 @@ export const restoreMongoBackup = async ( const rcloneFlags = getS3Credentials(destination); const bucketPath = `:s3:${destination.bucket}`; const backupPath = `${bucketPath}/${backupInput.backupFile}`; - const rcloneCommand = `rclone copy ${rcloneFlags.join(" ")} "${backupPath}"`; + const rcloneCommand = `rclone copy ${rcloneFlags.join(" ")} ${quote([backupPath])}`; const command = getRestoreCommand({ appName, diff --git a/packages/server/src/utils/restore/mysql.ts b/packages/server/src/utils/restore/mysql.ts index c955ef960..d237da895 100644 --- a/packages/server/src/utils/restore/mysql.ts +++ b/packages/server/src/utils/restore/mysql.ts @@ -1,6 +1,7 @@ import type { apiRestoreBackup } from "@dokploy/server/db/schema"; import type { Destination } from "@dokploy/server/services/destination"; import type { MySql } from "@dokploy/server/services/mysql"; +import { quote } from "shell-quote"; import type { z } from "zod"; import { getS3Credentials } from "../backups/utils"; import { execAsync, execAsyncRemote } from "../process/execAsync"; @@ -19,7 +20,7 @@ export const restoreMySqlBackup = async ( const bucketPath = `:s3:${destination.bucket}`; const backupPath = `${bucketPath}/${backupInput.backupFile}`; - const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`; + const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} ${quote([backupPath])} | gunzip`; const command = getRestoreCommand({ appName, diff --git a/packages/server/src/utils/restore/postgres.ts b/packages/server/src/utils/restore/postgres.ts index 8cbbe2175..05dcf38a3 100644 --- a/packages/server/src/utils/restore/postgres.ts +++ b/packages/server/src/utils/restore/postgres.ts @@ -1,6 +1,7 @@ import type { apiRestoreBackup } from "@dokploy/server/db/schema"; import type { Destination } from "@dokploy/server/services/destination"; import type { Postgres } from "@dokploy/server/services/postgres"; +import { quote } from "shell-quote"; import type { z } from "zod"; import { getS3Credentials } from "../backups/utils"; import { execAsync, execAsyncRemote } from "../process/execAsync"; @@ -20,7 +21,7 @@ export const restorePostgresBackup = async ( const backupPath = `${bucketPath}/${backupInput.backupFile}`; - const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`; + const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} ${quote([backupPath])} | gunzip`; const command = getRestoreCommand({ appName, diff --git a/packages/server/src/utils/restore/utils.ts b/packages/server/src/utils/restore/utils.ts index 18065fb99..dd2934430 100644 --- a/packages/server/src/utils/restore/utils.ts +++ b/packages/server/src/utils/restore/utils.ts @@ -92,8 +92,8 @@ rm -rf ${tempDir} && \ mkdir -p ${tempDir} && \ ${rcloneCommand} ${tempDir} && \ cd ${tempDir} && \ -gunzip -f "${fileName}" && \ -${restoreCommand} < "${decompressedName}" && \ +gunzip -f ${quote([fileName])} && \ +${restoreCommand} < ${quote([decompressedName])} && \ rm -rf ${tempDir} `; }; diff --git a/packages/server/src/utils/restore/web-server.ts b/packages/server/src/utils/restore/web-server.ts index 683a1898a..afcd81cf6 100644 --- a/packages/server/src/utils/restore/web-server.ts +++ b/packages/server/src/utils/restore/web-server.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { IS_CLOUD, paths } from "@dokploy/server/constants"; import type { Destination } from "@dokploy/server/services/destination"; +import { quote } from "shell-quote"; import { getS3Credentials } from "../backups/utils"; import { execAsync } from "../process/execAsync"; @@ -35,7 +36,7 @@ export const restoreWebServerBackup = async ( // Download backup from S3 emit("Downloading backup from S3..."); await execAsync( - `rclone copyto ${rcloneFlags.join(" ")} "${backupPath}" "${tempDir}/${backupFile}"`, + `rclone copyto ${rcloneFlags.join(" ")} ${quote([backupPath])} ${quote([`${tempDir}/${backupFile}`])}`, ); // List files before extraction @@ -45,7 +46,9 @@ export const restoreWebServerBackup = async ( // Extract backup emit("Extracting backup..."); - await execAsync(`cd ${tempDir} && unzip ${backupFile} > /dev/null 2>&1`); + await execAsync( + `cd ${quote([tempDir])} && unzip ${quote([backupFile])} > /dev/null 2>&1`, + ); // Restore filesystem first emit("Restoring filesystem...");