From a9244d8988e51c6622fc4b9d996ea0b825829f55 Mon Sep 17 00:00:00 2001 From: Rafael Zendron Date: Tue, 16 Jun 2026 15:44:25 -0300 Subject: [PATCH] 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 --- .../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 +- 4 files changed, 66 insertions(+), 2 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 0000000000..5fff508cc8 --- /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 8f0e0b6dd9..d0d3045587 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 0000000000..065e76d8cd --- /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 365ebff415..59e0450f93 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}`,