From f8a3561f1ed906821baf4ec32ca7b1f108bcdc13 Mon Sep 17 00:00:00 2001 From: Rafael Dias Zendron Date: Sun, 5 Jul 2026 19:52:03 -0300 Subject: [PATCH] fix(backup): redact S3 credentials from logs and error output (#4648) * fix(backup): redact S3 credentials from logs and error output (#4621) S3 backup credentials (access key + secret) were logged in plaintext to Dokploy service stdout via logger.info in getBackupCommand() and console.error in keepLatestNBackups(). Any operator with access to service logs could recover S3 credentials. Added redactRcloneCredentials() pure function that masks --s3-access-key-id and --s3-secret-access-key values with [REDACTED]. Applied to both the structured logger call and the error handler. Closes #4621 * fix(backups): redact sensitive information in error logs during web server backup process Updated error handling in the web server backup function to redact Rclone credentials from error messages before logging and notification. This change enhances security by preventing sensitive data exposure in logs. --------- Co-authored-by: Mauricio Siu --- .../backups/redact-credentials.test.ts | 50 +++++++++++++++++++ packages/server/src/utils/backups/index.ts | 3 +- packages/server/src/utils/backups/redact.ts | 12 +++++ packages/server/src/utils/backups/utils.ts | 3 +- .../server/src/utils/backups/web-server.ts | 18 ++++--- 5 files changed, 77 insertions(+), 9 deletions(-) create mode 100644 apps/dokploy/__test__/backups/redact-credentials.test.ts create mode 100644 packages/server/src/utils/backups/redact.ts diff --git a/apps/dokploy/__test__/backups/redact-credentials.test.ts b/apps/dokploy/__test__/backups/redact-credentials.test.ts new file mode 100644 index 000000000..5fff508cc --- /dev/null +++ b/apps/dokploy/__test__/backups/redact-credentials.test.ts @@ -0,0 +1,50 @@ +import { redactRcloneCredentials } from "@dokploy/server/utils/backups/redact"; +import { describe, expect, it } from "vitest"; + +describe("redactRcloneCredentials (#4621)", () => { + it("should redact access key in rclone command", () => { + const cmd = + 'rclone rcat --s3-access-key-id="AKIAIOSFODNN7EXAMPLE" --s3-secret-access-key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" :s3:bucket/file.gz'; + const redacted = redactRcloneCredentials(cmd); + expect(redacted).not.toContain("AKIAIOSFODNN7EXAMPLE"); + expect(redacted).toContain('--s3-access-key-id="[REDACTED]"'); + }); + + it("should redact secret access key in rclone command", () => { + const cmd = + 'rclone rcat --s3-access-key-id="key" --s3-secret-access-key="supersecret" :s3:bucket/file.gz'; + const redacted = redactRcloneCredentials(cmd); + expect(redacted).not.toContain("supersecret"); + expect(redacted).toContain('--s3-secret-access-key="[REDACTED]"'); + }); + + it("should redact both credentials simultaneously", () => { + const cmd = + 'rclone lsf --s3-access-key-id="AKIA123" --s3-secret-access-key="secret456" --s3-region="us-east-1" :s3:bucket/'; + const redacted = redactRcloneCredentials(cmd); + expect(redacted).not.toContain("AKIA123"); + expect(redacted).not.toContain("secret456"); + expect(redacted).toContain('--s3-region="us-east-1"'); + }); + + it("should not modify non-credential flags", () => { + const cmd = + 'rclone rcat --s3-region="eu-west-1" --s3-endpoint="https://s3.example.com" --s3-no-check-bucket :s3:bucket/file.gz'; + const redacted = redactRcloneCredentials(cmd); + expect(redacted).toBe(cmd); + }); + + it("should handle commands with no credentials", () => { + const cmd = "rclone lsf :s3:bucket/"; + expect(redactRcloneCredentials(cmd)).toBe(cmd); + }); + + it("should handle error strings containing credentials", () => { + const errorStr = + 'Error: Command failed: rclone lsf --s3-access-key-id="MYKEY" --s3-secret-access-key="MYSECRET" :s3:bucket/'; + const redacted = redactRcloneCredentials(errorStr); + expect(redacted).not.toContain("MYKEY"); + expect(redacted).not.toContain("MYSECRET"); + expect(redacted).toContain("[REDACTED]"); + }); +}); diff --git a/packages/server/src/utils/backups/index.ts b/packages/server/src/utils/backups/index.ts index 8f0e0b6dd..d0d304558 100644 --- a/packages/server/src/utils/backups/index.ts +++ b/packages/server/src/utils/backups/index.ts @@ -11,6 +11,7 @@ import { startLogCleanup } from "../access-log/handler"; import { cleanupAll } from "../docker/utils"; import { sendDockerCleanupNotifications } from "../notifications/docker-cleanup"; import { execAsync, execAsyncRemote } from "../process/execAsync"; +import { redactRcloneCredentials } from "./redact"; import { getS3Credentials, normalizeS3Path, scheduleBackup } from "./utils"; export const initCronJobs = async () => { @@ -153,6 +154,6 @@ export const keepLatestNBackups = async ( await execAsync(rcloneCommand); } } catch (error) { - console.error(error); + console.error(redactRcloneCredentials(String(error))); } }; diff --git a/packages/server/src/utils/backups/redact.ts b/packages/server/src/utils/backups/redact.ts new file mode 100644 index 000000000..065e76d8c --- /dev/null +++ b/packages/server/src/utils/backups/redact.ts @@ -0,0 +1,12 @@ +/** + * Redacts S3 credentials from rclone command strings. + * + * Used to prevent credential leakage in structured logs and error output. + * Matches the flag format produced by `getS3Credentials()`: + * --s3-access-key-id="VALUE" and --s3-secret-access-key="VALUE" + */ +export const redactRcloneCredentials = (command: string): string => { + return command + .replace(/(--s3-access-key-id=)"[^"]*"/g, '$1"[REDACTED]"') + .replace(/(--s3-secret-access-key=)"[^"]*"/g, '$1"[REDACTED]"'); +}; diff --git a/packages/server/src/utils/backups/utils.ts b/packages/server/src/utils/backups/utils.ts index 365ebff41..59e0450f9 100644 --- a/packages/server/src/utils/backups/utils.ts +++ b/packages/server/src/utils/backups/utils.ts @@ -9,6 +9,7 @@ import { runMariadbBackup } from "./mariadb"; import { runMongoBackup } from "./mongo"; import { runMySqlBackup } from "./mysql"; import { runPostgresBackup } from "./postgres"; +import { redactRcloneCredentials } from "./redact"; import { runWebServerBackup } from "./web-server"; export const scheduleBackup = (backup: BackupSchedule) => { @@ -262,7 +263,7 @@ export const getBackupCommand = ( { containerSearch, backupCommand, - rcloneCommand, + rcloneCommand: redactRcloneCredentials(rcloneCommand), logPath, }, `Executing backup command: ${backup.databaseType} ${backup.backupType}`, diff --git a/packages/server/src/utils/backups/web-server.ts b/packages/server/src/utils/backups/web-server.ts index 712cc0809..1eaf1981d 100644 --- a/packages/server/src/utils/backups/web-server.ts +++ b/packages/server/src/utils/backups/web-server.ts @@ -11,6 +11,7 @@ import { import { findDestinationById } from "@dokploy/server/services/destination"; import { sendDokployBackupNotifications } from "../notifications/dokploy-backup"; import { execAsync } from "../process/execAsync"; +import { redactRcloneCredentials } from "./redact"; import { getBackupTimestamp, getS3Credentials, normalizeS3Path } from "./utils"; function formatBytes(bytes?: number) { @@ -113,20 +114,23 @@ export const runWebServerBackup = async (backup: BackupSchedule) => { try { await rm(tempDir, { recursive: true, force: true }); } catch (cleanupError) { - console.error("Cleanup error:", cleanupError); + console.error( + "Cleanup error:", + redactRcloneCredentials(String(cleanupError)), + ); } } } catch (error) { - console.error("Backup error:", error); - writeStream.write("Backup error❌\n"); - writeStream.write( - error instanceof Error ? error.message : "Unknown error\n", + const safeErrorMessage = redactRcloneCredentials( + error instanceof Error ? error.message : String(error), ); + console.error("Backup error:", redactRcloneCredentials(String(error))); + writeStream.write("Backup error❌\n"); + writeStream.write(`${safeErrorMessage}\n`); writeStream.end(); await sendDokployBackupNotifications({ type: "error", - // @ts-ignore - errorMessage: error?.message || "Error message not provided", + errorMessage: safeErrorMessage || "Error message not provided", backupSize: formatBytes(computedBackupSize), }); await updateDeploymentStatus(deployment.deploymentId, "error");