Files
dokploy/packages/server/src/utils/restore/compose.ts
Mauricio Siu eeb6e7b8ea 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.
2026-07-20 16:11:16 -06:00

104 lines
2.8 KiB
TypeScript

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";
import { getRestoreCommand } from "./utils";
interface DatabaseCredentials {
databaseUser?: string;
databasePassword?: string;
}
export const restoreComposeBackup = async (
compose: Compose,
destination: Destination,
backupInput: z.infer<typeof apiRestoreBackup>,
emit: (log: string) => void,
) => {
try {
if (backupInput.databaseType === "web-server") {
return;
}
const { serverId, appName, composeType } = compose;
const rcloneFlags = getS3Credentials(destination);
const bucketPath = `:s3:${destination.bucket}`;
const backupPath = `${bucketPath}/${backupInput.backupFile}`;
let rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} ${quote([backupPath])} | gunzip`;
if (backupInput.metadata?.mongo) {
rcloneCommand = `rclone copy ${rcloneFlags.join(" ")} ${quote([backupPath])}`;
}
let credentials: DatabaseCredentials = {};
switch (backupInput.databaseType) {
case "postgres":
credentials = {
databaseUser: backupInput.metadata?.postgres?.databaseUser,
};
break;
case "mariadb":
credentials = {
databaseUser: backupInput.metadata?.mariadb?.databaseUser,
databasePassword: backupInput.metadata?.mariadb?.databasePassword,
};
break;
case "mysql":
credentials = {
databasePassword: backupInput.metadata?.mysql?.databaseRootPassword,
};
break;
case "mongo":
credentials = {
databaseUser: backupInput.metadata?.mongo?.databaseUser,
databasePassword: backupInput.metadata?.mongo?.databasePassword,
};
break;
}
const restoreCommand = getRestoreCommand({
appName: appName,
serviceName: backupInput.metadata?.serviceName,
type: backupInput.databaseType as
| "postgres"
| "mariadb"
| "mysql"
| "mongo",
credentials: {
database: backupInput.databaseName,
...credentials,
},
restoreType: composeType,
rcloneCommand,
backupFile: backupInput.backupFile,
});
emit("Starting restore...");
emit(
`Restoring database: ${backupInput.databaseName} from ${backupInput.backupFile}`,
);
if (serverId) {
await execAsyncRemote(serverId, restoreCommand);
} else {
await execAsync(restoreCommand);
}
emit("Restore completed successfully!");
} catch (error) {
console.error(error);
emit(
`Error: ${
error instanceof Error ? error.message : "Error restoring mongo backup"
}`,
);
throw new Error(
error instanceof Error ? error.message : "Error restoring mongo backup",
);
}
};