From 10de49aa8e6c7c3d5315101030b047cabf4a0de8 Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:42:29 -0400 Subject: [PATCH] fix: redact secrets in nested relations of backup, schedule and certificate reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Что: - volumeBackups.one, schedule.one и schedule.list прогоняют вложенные сервисы, окружение, проект и сервер через новый общий помощник redactServiceRelationSecrets. - backup.one теперь скрывает связанный compose (env, composeFile, учетные данные в customGitUrl, refreshToken); redactBackupScheduleSecrets использует тот же помощник. - compose.one и compose.delete скрывают пароли в metadata бэкапов (mariadb/mongo databasePassword, mysql databaseRootPassword). - certificate.all скрывает command и metrics token сервера через redactServer. - user.getBackups больше не выбирает accessKey и secretAccessKey назначения. Зачем: - Эти процедуры чтения отдавали пароли баз данных, env, compose-файлы, токены и S3-ключи всем, у кого есть право чтения. Риски: - Скрытые поля теперь приходят как __DOKPLOY_REDACTED_SECRET__ или отсутствуют. UI их не читает: форма бэкапа берет metadata из backup.one (уже скрыто, update сохраняет placeholder), список сертификатов читает только name и ipAddress сервера. Внешние клиенты API, которые читали эти вложенные поля, получат placeholder. Проверки: - Команды и результаты: CI=true bunx vitest run --config __test__/vitest.config.ts по новым и измененным тестам — 9 новых тестов проходят и падают на origin/AgentHits-Dev (исходники временно возвращались через git checkout); папки security, schedule, backup, backups, volume-backups, certificate, compose, users — 73 файла, 522 теста прошли; bun run typecheck — exit 0; bunx biome check по 12 измененным файлам — 0 ошибок. - Ограничения: UI в браузере не проверялся. What: - volumeBackups.one, schedule.one and schedule.list pass nested services, environment, project and server through a new shared helper, redactServiceRelationSecrets. - backup.one now redacts the bound compose (env, composeFile, customGitUrl credentials, refreshToken); redactBackupScheduleSecrets uses the same helper. - compose.one and compose.delete redact backup metadata passwords (mariadb/mongo databasePassword, mysql databaseRootPassword). - certificate.all redacts the server command and metrics token with redactServer. - user.getBackups no longer selects destination accessKey and secretAccessKey. Why: - These read procedures returned database passwords, env, compose files, tokens and S3 keys to anyone with read access. Risks: - Redacted fields now come back as __DOKPLOY_REDACTED_SECRET__ or are missing. The UI does not read them: the backup form loads metadata from backup.one (already redacted, update keeps the placeholder), the certificate list reads only the server name and ipAddress. External API clients that read these nested fields will get the placeholder. Checks: - Commands and results: CI=true bunx vitest run --config __test__/vitest.config.ts on the new and changed tests: the 9 new tests pass and fail on origin/AgentHits-Dev (sources temporarily restored with git checkout); folders security, schedule, backup, backups, volume-backups, certificate, compose, users: 73 files, 522 tests passed; bun run typecheck: exit 0; bunx biome check on the 12 changed files: 0 errors. - Limitations: the UI was not checked in a browser. Co-Authored-By: Claude Opus 5.5 --- .../__test__/backup/restore-security.test.ts | 57 +++++ ...certificate-router-assigned-server.test.ts | 33 +++ .../schedule/schedule-read-redaction.test.ts | 219 ++++++++++++++++++ .../service-environment-reveal.test.ts | 84 ++++++- .../users/user-backups-boundary.test.ts | 95 ++++++++ .../volume-backups/restore-security.test.ts | 117 ++++++++++ .../dokploy/server/api/routers/certificate.ts | 14 +- apps/dokploy/server/api/routers/compose.ts | 13 +- apps/dokploy/server/api/routers/schedule.ts | 6 +- apps/dokploy/server/api/routers/user.ts | 7 +- .../server/api/routers/volume-backups.ts | 3 +- .../server/src/utils/security/redaction.ts | 54 ++++- 12 files changed, 684 insertions(+), 18 deletions(-) create mode 100644 apps/dokploy/__test__/schedule/schedule-read-redaction.test.ts create mode 100644 apps/dokploy/__test__/users/user-backups-boundary.test.ts diff --git a/apps/dokploy/__test__/backup/restore-security.test.ts b/apps/dokploy/__test__/backup/restore-security.test.ts index 9a4bcfa680a..e999bc40dc4 100644 --- a/apps/dokploy/__test__/backup/restore-security.test.ts +++ b/apps/dokploy/__test__/backup/restore-security.test.ts @@ -1,3 +1,4 @@ +import { REDACTED_SECRET_VALUE } from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { parse } from "shell-quote"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -1225,3 +1226,59 @@ describe("backup restore command safety", () => { ); }); }); + +describe("backup read redaction", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.checkServicePermissionAndAccess.mockResolvedValue(undefined); + }); + + it("redacts the bound compose service and metadata passwords in one", async () => { + mocks.findBackupById.mockResolvedValue({ + backupId: "backup-1", + backupType: "compose", + databaseType: "mariadb", + destinationId: "destination-1", + composeId: "compose-1", + serviceName: "db", + metadata: { + mariadb: { + databaseUser: "app", + databasePassword: "metadata-password", + }, + }, + compose: { + composeId: "compose-1", + appName: "compose-one", + env: "COMPOSE_SECRET=compose-env-secret", + composeFile: + "services:\n db:\n environment:\n PASS: file-secret", + customGitUrl: "https://repo-user:repo-token@example.com/org/repo.git", + refreshToken: "compose-refresh-token", + }, + }); + + const backup = await createCaller().one({ backupId: "backup-1" }); + + expect(backup.compose).toMatchObject({ + appName: "compose-one", + env: REDACTED_SECRET_VALUE, + composeFile: REDACTED_SECRET_VALUE, + customGitUrl: `https://${REDACTED_SECRET_VALUE}@example.com/org/repo.git`, + refreshToken: REDACTED_SECRET_VALUE, + }); + expect(backup.metadata).toEqual({ + mariadb: { databaseUser: "app", databasePassword: REDACTED_SECRET_VALUE }, + }); + const serialized = JSON.stringify(backup); + for (const secret of [ + "compose-env-secret", + "file-secret", + "repo-token", + "compose-refresh-token", + "metadata-password", + ]) { + expect(serialized).not.toContain(secret); + } + }); +}); diff --git a/apps/dokploy/__test__/certificate/certificate-router-assigned-server.test.ts b/apps/dokploy/__test__/certificate/certificate-router-assigned-server.test.ts index dcca2fd4c1b..211c72acd20 100644 --- a/apps/dokploy/__test__/certificate/certificate-router-assigned-server.test.ts +++ b/apps/dokploy/__test__/certificate/certificate-router-assigned-server.test.ts @@ -144,6 +144,39 @@ describe("certificate router assigned-server boundary", () => { ]); }); + it("redacts the assigned server's command and metrics token from all", async () => { + mocks.certificateFindMany.mockResolvedValue([ + { + certificateId: "certificate-1", + name: "accessible", + privateKey: "private-key", + organizationId: "org-1", + serverId: "server-1", + server: { + serverId: "server-1", + name: "remote", + command: "curl -H 'Authorization: Bearer server-secret' example.com", + metricsConfig: { + server: { token: "metrics-token", port: 4500 }, + containers: { refreshRate: 60 }, + }, + }, + }, + ]); + + const [certificate] = await createCaller().all(); + + expect(certificate?.server).toMatchObject({ + name: "remote", + command: REDACTED_SECRET_VALUE, + metricsConfig: { + server: { token: REDACTED_SECRET_VALUE, port: 4500 }, + }, + }); + expect(JSON.stringify(certificate)).not.toContain("server-secret"); + expect(JSON.stringify(certificate)).not.toContain("metrics-token"); + }); + it("preserves stored certificate private keys when update receives the redacted placeholder", async () => { const result = await createCaller().update({ certificateId: "certificate-1", diff --git a/apps/dokploy/__test__/schedule/schedule-read-redaction.test.ts b/apps/dokploy/__test__/schedule/schedule-read-redaction.test.ts new file mode 100644 index 00000000000..c5d38481dc9 --- /dev/null +++ b/apps/dokploy/__test__/schedule/schedule-read-redaction.test.ts @@ -0,0 +1,219 @@ +import { REDACTED_SECRET_VALUE } from "@dokploy/server/utils/security/redaction"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + checkPermission: vi.fn(), + checkServicePermissionAndAccess: vi.fn(), + findMemberByUserId: vi.fn(), + findScheduleById: vi.fn(), + schedulesFindMany: vi.fn(), +})); + +vi.mock("@dokploy/server", () => ({ + IS_CLOUD: false, + removeScheduleJob: vi.fn(), + scheduleJob: vi.fn(), +})); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + schedules: { + findMany: mocks.schedulesFindMany, + }, + }, + }, +})); + +vi.mock("@dokploy/server/index", () => ({ + IS_CLOUD: false, + removeScheduleJob: vi.fn(), + runCommand: vi.fn(), + scheduleJob: vi.fn(), +})); + +vi.mock("@dokploy/server/services/permission", () => ({ + checkPermission: mocks.checkPermission, + checkServicePermissionAndAccess: mocks.checkServicePermissionAndAccess, + findMemberByUserId: mocks.findMemberByUserId, +})); + +vi.mock("@dokploy/server/services/schedule", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + findScheduleById: mocks.findScheduleById, + }; +}); + +vi.mock("@/server/api/utils/audit", () => ({ + audit: vi.fn(), +})); + +vi.mock("@/server/api/utils/placement-access", () => ({ + assertTargetServerAccess: vi.fn(), +})); + +vi.mock("@/server/utils/backup", () => ({ + removeJob: vi.fn(), + schedule: vi.fn(), +})); + +const { scheduleRouter } = await import("../../server/api/routers/schedule"); + +const createCaller = () => + scheduleRouter.createCaller({ + user: { id: "user-1" }, + session: { activeOrganizationId: "org-1" }, + } as never); + +const sharedEnvironment = { + environmentId: "environment-1", + env: "ENVIRONMENT_SHARED=environment-secret", + project: { + projectId: "project-1", + organizationId: "org-1", + env: "PROJECT_SHARED=project-secret", + }, +}; + +const remoteServer = { + serverId: "server-1", + name: "remote", + organizationId: "org-1", + command: "echo server-command-secret", + metricsConfig: { + server: { token: "metrics-token", port: 4500 }, + containers: { refreshRate: 60 }, + }, +}; + +const expectNoSecrets = (value: unknown) => { + const serialized = JSON.stringify(value); + for (const secret of [ + "compose-env-secret", + "file-secret", + "repo-token", + "compose-refresh-token", + "environment-secret", + "project-secret", + "server-command-secret", + "metrics-token", + ]) { + expect(serialized).not.toContain(secret); + } +}; + +describe("schedule read redaction", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.checkPermission.mockResolvedValue(undefined); + mocks.checkServicePermissionAndAccess.mockResolvedValue(undefined); + mocks.findMemberByUserId.mockResolvedValue({ role: "admin" }); + }); + + it("redacts the bound compose service and shared env in one", async () => { + mocks.findScheduleById.mockResolvedValue({ + scheduleId: "schedule-1", + name: "nightly", + scheduleType: "compose", + command: "echo ok", + applicationId: null, + composeId: "compose-1", + serverId: null, + organizationId: null, + application: null, + server: null, + compose: { + composeId: "compose-1", + appName: "compose-one", + env: "COMPOSE_SECRET=compose-env-secret", + composeFile: + "services:\n api:\n environment:\n TOKEN: file-secret", + customGitUrl: "https://repo-user:repo-token@example.com/org/repo.git", + refreshToken: "compose-refresh-token", + environment: sharedEnvironment, + }, + }); + + const schedule = await createCaller().one({ scheduleId: "schedule-1" }); + + expect(schedule).toMatchObject({ + command: "echo ok", + compose: { + appName: "compose-one", + env: REDACTED_SECRET_VALUE, + composeFile: REDACTED_SECRET_VALUE, + refreshToken: REDACTED_SECRET_VALUE, + environment: { + env: REDACTED_SECRET_VALUE, + project: { env: REDACTED_SECRET_VALUE, organizationId: "org-1" }, + }, + }, + }); + expectNoSecrets(schedule); + }); + + it("redacts the bound server and application shared env in one", async () => { + mocks.findScheduleById.mockResolvedValue({ + scheduleId: "schedule-1", + name: "nightly", + scheduleType: "server", + command: "echo ok", + applicationId: null, + composeId: null, + serverId: "server-1", + organizationId: null, + application: { + applicationId: "app-1", + appName: "app-one", + environment: sharedEnvironment, + }, + compose: null, + server: { ...remoteServer, organization: { id: "org-1" } }, + }); + + const schedule = await createCaller().one({ scheduleId: "schedule-1" }); + + expect(schedule.server).toMatchObject({ + name: "remote", + command: REDACTED_SECRET_VALUE, + metricsConfig: { server: { token: REDACTED_SECRET_VALUE } }, + organization: { id: "org-1" }, + }); + expectNoSecrets(schedule); + }); + + it("redacts the server row in list", async () => { + mocks.schedulesFindMany.mockResolvedValue([ + { + scheduleId: "schedule-1", + name: "nightly", + scheduleType: "server", + command: "echo ok", + serverId: "server-1", + application: null, + compose: null, + server: remoteServer, + deployments: [], + }, + ]); + + const schedules = await createCaller().list({ + id: "server-1", + scheduleType: "server", + }); + + expect(schedules).toHaveLength(1); + expect(schedules[0]).toMatchObject({ + command: "echo ok", + server: { + name: "remote", + command: REDACTED_SECRET_VALUE, + metricsConfig: { server: { token: REDACTED_SECRET_VALUE, port: 4500 } }, + }, + }); + expectNoSecrets(schedules); + }); +}); diff --git a/apps/dokploy/__test__/security/service-environment-reveal.test.ts b/apps/dokploy/__test__/security/service-environment-reveal.test.ts index 88f2ae7cce3..19292261dbe 100644 --- a/apps/dokploy/__test__/security/service-environment-reveal.test.ts +++ b/apps/dokploy/__test__/security/service-environment-reveal.test.ts @@ -147,7 +147,11 @@ vi.mock("@dokploy/server", () => ({ })); vi.mock("@dokploy/server/db", () => ({ - db: {}, + db: { + delete: () => ({ + where: () => ({ returning: async () => [] }), + }), + }, })); vi.mock("@dokploy/server/services/git-provider", () => ({ @@ -406,6 +410,84 @@ describe("service environment reveal boundary", () => { } }); + it("redacts compose backup metadata passwords in one and delete", async () => { + permissionMocks.hasPermission.mockResolvedValue(true); + serverMocks.findComposeById.mockResolvedValue({ + ...compose(), + backups: [ + { + backupId: "backup-1", + backupType: "compose", + databaseType: "mariadb", + serviceName: "db", + destination: { destinationId: "destination-1", name: "bucket" }, + deployments: [], + metadata: { + mariadb: { + databaseUser: "app", + databasePassword: "mariadb-password", + }, + }, + }, + { + backupId: "backup-2", + backupType: "compose", + databaseType: "mysql", + serviceName: "mysql", + metadata: { mysql: { databaseRootPassword: "mysql-root-password" } }, + }, + { + backupId: "backup-3", + backupType: "compose", + databaseType: "mongo", + serviceName: "mongo", + metadata: { + mongo: { databaseUser: "app", databasePassword: "mongo-password" }, + }, + }, + ], + }); + + const caller = composeRouter.createCaller(createContext()); + const read = await caller.one({ composeId: "compose-1" }); + const deleted = await caller.delete({ + composeId: "compose-1", + deleteVolumes: false, + }); + + for (const response of [read, deleted]) { + expect(response.backups).toEqual([ + expect.objectContaining({ + backupId: "backup-1", + destination: { destinationId: "destination-1", name: "bucket" }, + metadata: { + mariadb: { + databaseUser: "app", + databasePassword: REDACTED_SECRET_VALUE, + }, + }, + }), + expect.objectContaining({ + backupId: "backup-2", + metadata: { mysql: { databaseRootPassword: REDACTED_SECRET_VALUE } }, + }), + expect.objectContaining({ + backupId: "backup-3", + metadata: { + mongo: { + databaseUser: "app", + databasePassword: REDACTED_SECRET_VALUE, + }, + }, + }), + ]); + const serialized = JSON.stringify(response); + expect(serialized).not.toContain("mariadb-password"); + expect(serialized).not.toContain("mysql-root-password"); + expect(serialized).not.toContain("mongo-password"); + } + }); + it("hides shared env in compose reads", async () => { const normalRead = await composeRouter .createCaller(createContext()) diff --git a/apps/dokploy/__test__/users/user-backups-boundary.test.ts b/apps/dokploy/__test__/users/user-backups-boundary.test.ts new file mode 100644 index 00000000000..9dd7d2111b8 --- /dev/null +++ b/apps/dokploy/__test__/users/user-backups-boundary.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { relationalQueryDb } from "../helpers/postgres-function-args"; + +const mocks = vi.hoisted(() => ({ + memberFindFirst: vi.fn(), +})); + +vi.mock("@dokploy/server", () => ({ + IS_CLOUD: false, +})); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + member: { + findFirst: mocks.memberFindFirst, + }, + }, + }, +})); + +vi.mock("@dokploy/server/lib/auth", () => ({ + validateRequest: vi.fn(), +})); + +vi.mock("@dokploy/server/services/permission", () => ({ + checkPermission: vi.fn(), + hasPermission: vi.fn(), + resolvePermissions: vi.fn(), +})); + +vi.mock("@dokploy/server/services/proprietary/license-key", () => ({ + hasValidLicense: vi.fn(), +})); + +vi.mock("@/server/api/utils/audit", () => ({ + audit: vi.fn(), +})); + +const { userRouter } = await import("../../server/api/routers/user"); + +const createCaller = () => + userRouter.createCaller({ + db: {}, + req: {}, + res: {}, + session: { + userId: "user-1", + activeOrganizationId: "org-1", + }, + user: { + id: "user-1", + role: "owner", + }, + } as never); + +describe("user.getBackups destination credentials", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("does not select destination access keys for web-server backups", async () => { + let compiledSql = ""; + mocks.memberFindFirst.mockImplementation(async (config) => { + compiledSql = relationalQueryDb.query.member + .findFirst(config) + .toSQL().sql; + return { + user: { + id: "user-1", + backups: [ + { + backupId: "backup-1", + destination: { destinationId: "destination-1", name: "bucket" }, + deployments: [], + }, + ], + }, + }; + }); + + await expect(createCaller().getBackups()).resolves.toMatchObject({ + backups: [ + { + backupId: "backup-1", + destination: { destinationId: "destination-1", name: "bucket" }, + }, + ], + }); + + expect(compiledSql).toContain('"destinationId"'); + expect(compiledSql).not.toContain('"accessKey"'); + expect(compiledSql).not.toContain('"secretAccessKey"'); + }); +}); diff --git a/apps/dokploy/__test__/volume-backups/restore-security.test.ts b/apps/dokploy/__test__/volume-backups/restore-security.test.ts index 74ae95b7125..608868eb493 100644 --- a/apps/dokploy/__test__/volume-backups/restore-security.test.ts +++ b/apps/dokploy/__test__/volume-backups/restore-security.test.ts @@ -1,3 +1,4 @@ +import { REDACTED_SECRET_VALUE } from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { @@ -510,6 +511,122 @@ describe("volume backup destination ownership boundary", () => { }); }); +describe("volume backup read redaction", () => { + const sharedEnvironment = { + environmentId: "env-1", + env: "ENVIRONMENT_SHARED=environment-secret", + project: { ...project(), env: "PROJECT_SHARED=project-secret" }, + }; + + const expectNoSecrets = (value: unknown) => { + const serialized = JSON.stringify(value); + for (const secret of [ + "db-password", + "db-root-password", + "db-env-secret", + "compose-env-secret", + "file-secret", + "repo-token", + "compose-refresh-token", + "environment-secret", + "project-secret", + ]) { + expect(serialized).not.toContain(secret); + } + }; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.checkServicePermissionAndAccess.mockResolvedValue(undefined); + mocks.findMemberByUserId.mockResolvedValue({ + role: "admin", + accessedEnvironments: [], + accessedProjects: [], + accessedServices: [], + }); + }); + + it("redacts database credentials and shared env of the bound database", async () => { + mocks.findPostgresById.mockResolvedValue({ + postgresId: "postgres-1", + environmentId: "env-1", + serverId: null, + environment: { environmentId: "env-1", project: project() }, + }); + mocks.findVolumeBackupById.mockResolvedValue({ + volumeBackupId: "volume-backup-1", + name: "daily volume", + serviceType: "postgres", + postgresId: "postgres-1", + volumeName: "data_volume", + postgres: { + postgresId: "postgres-1", + appName: "postgres-one", + databaseUser: "app", + databasePassword: "db-password", + databaseRootPassword: "db-root-password", + env: "POSTGRES_EXTRA=db-env-secret", + environment: sharedEnvironment, + }, + }); + + const volumeBackup = await createCaller().one({ + volumeBackupId: "volume-backup-1", + }); + + expect(volumeBackup).toMatchObject({ + name: "daily volume", + volumeName: "data_volume", + postgres: { + appName: "postgres-one", + databaseUser: "app", + databasePassword: REDACTED_SECRET_VALUE, + databaseRootPassword: REDACTED_SECRET_VALUE, + env: REDACTED_SECRET_VALUE, + environment: { + env: REDACTED_SECRET_VALUE, + project: { env: REDACTED_SECRET_VALUE, organizationId: "org-1" }, + }, + }, + }); + expectNoSecrets(volumeBackup); + }); + + it("redacts env, compose file and source credentials of the bound compose", async () => { + mocks.findComposeById.mockResolvedValue(composeService("compose-1")); + mocks.findVolumeBackupById.mockResolvedValue({ + volumeBackupId: "volume-backup-1", + name: "daily volume", + serviceType: "compose", + composeId: "compose-1", + volumeName: "data_volume", + compose: { + composeId: "compose-1", + appName: "compose-one", + env: "COMPOSE_SECRET=compose-env-secret", + composeFile: + "services:\n api:\n environment:\n TOKEN: file-secret", + customGitUrl: "https://repo-user:repo-token@example.com/org/repo.git", + refreshToken: "compose-refresh-token", + environment: sharedEnvironment, + }, + }); + + const volumeBackup = await createCaller().one({ + volumeBackupId: "volume-backup-1", + }); + + expect(volumeBackup.compose).toMatchObject({ + appName: "compose-one", + env: REDACTED_SECRET_VALUE, + composeFile: REDACTED_SECRET_VALUE, + customGitUrl: `https://${REDACTED_SECRET_VALUE}@example.com/org/repo.git`, + refreshToken: REDACTED_SECRET_VALUE, + }); + expectNoSecrets(volumeBackup); + }); +}); + describe("volume backup restore command safety", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/apps/dokploy/server/api/routers/certificate.ts b/apps/dokploy/server/api/routers/certificate.ts index 9becfc42fbd..cb17d673de0 100644 --- a/apps/dokploy/server/api/routers/certificate.ts +++ b/apps/dokploy/server/api/routers/certificate.ts @@ -7,6 +7,7 @@ import { updateCertificate, } from "@dokploy/server"; import { db } from "@dokploy/server/db"; +import { redactServer } from "@dokploy/server/services/server"; import { isRedactedSecretValue, redactSecretFields, @@ -106,10 +107,15 @@ export const certificateRouter = createTRPCRouter({ }); const accessibleIds = await getAccessibleServerIds(ctx.session); return redactSecretFieldsList( - allCertificates.filter( - (certificate) => - !certificate.serverId || accessibleIds.has(certificate.serverId), - ), + allCertificates + .filter( + (certificate) => + !certificate.serverId || accessibleIds.has(certificate.serverId), + ) + .map((certificate) => ({ + ...certificate, + server: redactServer(certificate.server), + })), ["privateKey"], ); }), diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts index 1669b4f3652..0383856d8b2 100644 --- a/apps/dokploy/server/api/routers/compose.ts +++ b/apps/dokploy/server/api/routers/compose.ts @@ -60,6 +60,7 @@ import { processTemplate } from "@dokploy/server/templates/processors"; import { assertCustomGitUrlAllowed } from "@dokploy/server/utils/providers/git"; import { preserveSecretPlaceholderFields, + redactBackupScheduleSecrets, redactDeployableServiceSecretsFor, redactSecretFields, type SharedEnvReadAccess, @@ -134,10 +135,18 @@ const redactComposeSecrets = ( ), sharedEnvAccess, ); + const withBackups = Array.isArray(record.backups) + ? ({ + ...redacted, + backups: record.backups.map((backup) => + redactBackupScheduleSecrets(backup), + ), + } as typeof redacted) + : redacted; return redactComposeFile - ? redactSecretFields(redacted, ["composeFile"]) - : redacted; + ? redactSecretFields(withBackups, ["composeFile"]) + : withBackups; }; const composeSourceUpdateFields = [ diff --git a/apps/dokploy/server/api/routers/schedule.ts b/apps/dokploy/server/api/routers/schedule.ts index 93f37f029d7..954ab335f12 100644 --- a/apps/dokploy/server/api/routers/schedule.ts +++ b/apps/dokploy/server/api/routers/schedule.ts @@ -20,6 +20,7 @@ import { updateSchedule, } from "@dokploy/server/services/schedule"; import { signScheduledQueueJob } from "@dokploy/server/utils/schedules/signed-job"; +import { redactServiceRelationSecrets } from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { asc, desc, eq } from "drizzle-orm"; import { z } from "zod"; @@ -399,7 +400,7 @@ export const scheduleRouter = createTRPCRouter({ ctx.session.activeOrganizationId, ), }; - return db.query.schedules.findMany({ + const scheduleList = await db.query.schedules.findMany({ where: where[input.scheduleType], orderBy: [asc(schedules.createdAt)], with: { @@ -425,6 +426,7 @@ export const scheduleRouter = createTRPCRouter({ }, }, }); + return scheduleList.map((item) => redactServiceRelationSecrets(item)); }), one: protectedProcedure @@ -440,7 +442,7 @@ export const scheduleRouter = createTRPCRouter({ await checkPermission(ctx, { schedule: ["read"] }); await assertServerLevelScheduleAccess(ctx, schedule); } - return schedule; + return redactServiceRelationSecrets(schedule); }), runManually: protectedProcedure diff --git a/apps/dokploy/server/api/routers/user.ts b/apps/dokploy/server/api/routers/user.ts index 76bf5bd7532..329eb032994 100644 --- a/apps/dokploy/server/api/routers/user.ts +++ b/apps/dokploy/server/api/routers/user.ts @@ -430,7 +430,12 @@ export const userRouter = createTRPCRouter({ with: { backups: { with: { - destination: true, + destination: { + columns: { + accessKey: false, + secretAccessKey: false, + }, + }, deployments: true, }, }, diff --git a/apps/dokploy/server/api/routers/volume-backups.ts b/apps/dokploy/server/api/routers/volume-backups.ts index 56366986dd1..eab9fa2628e 100644 --- a/apps/dokploy/server/api/routers/volume-backups.ts +++ b/apps/dokploy/server/api/routers/volume-backups.ts @@ -26,6 +26,7 @@ import { execAsyncStream, } from "@dokploy/server/utils/process/execAsync"; import { signScheduledQueueJob } from "@dokploy/server/utils/schedules/signed-job"; +import { redactServiceRelationSecrets } from "@dokploy/server/utils/security/redaction"; import { normalizeDockerVolumeName, normalizeVolumeBackupServiceName, @@ -417,7 +418,7 @@ export const volumeBackupsRouter = createTRPCRouter({ .query(async ({ input, ctx }) => { const vb = await findVolumeBackupById(input.volumeBackupId); await assertVolumeBackupServiceAccess(ctx, vb, "read"); - return vb; + return redactServiceRelationSecrets(vb); }), delete: protectedProcedure .input( diff --git a/packages/server/src/utils/security/redaction.ts b/packages/server/src/utils/security/redaction.ts index 11b16e277ce..472f3682508 100644 --- a/packages/server/src/utils/security/redaction.ts +++ b/packages/server/src/utils/security/redaction.ts @@ -232,7 +232,16 @@ export const redactBackupMetadataSecrets = (metadata: T): T => { return redacted as T; }; -export const redactBackupScheduleSecrets = < +export const redactComposeServiceSecrets = < + T extends SecretRecord | null | undefined, +>( + record: T, +) => + redactSecretFields(redactDeployableServiceSecrets(record), ["composeFile"]); + +// For records such as backups, volume backups and schedules that load the +// service (and its environment/project) or server they are bound to. +export const redactServiceRelationSecrets = < T extends SecretRecord | null | undefined, >( record: T, @@ -241,22 +250,53 @@ export const redactBackupScheduleSecrets = < return record; } - const redacted = { - ...record, - metadata: redactBackupMetadataSecrets(record.metadata), - }; - - for (const key of ["postgres", "mysql", "mariadb", "mongo", "libsql"]) { + const redacted: SecretRecord = { ...record }; + if (redacted.application && typeof redacted.application === "object") { + redacted.application = redactDeployableServiceSecrets( + redacted.application as SecretRecord, + ); + } + if (redacted.compose && typeof redacted.compose === "object") { + redacted.compose = redactComposeServiceSecrets( + redacted.compose as SecretRecord, + ); + } + for (const key of [ + "postgres", + "mysql", + "mariadb", + "mongo", + "redis", + "libsql", + ]) { if (redacted[key] && typeof redacted[key] === "object") { redacted[key] = redactDatabaseServiceSecrets( redacted[key] as SecretRecord, ); } } + if ("server" in redacted) { + redacted.server = redactNestedServerSecrets(redacted.server); + } return redacted as T; }; +export const redactBackupScheduleSecrets = < + T extends SecretRecord | null | undefined, +>( + record: T, +) => { + if (!record) { + return record; + } + + return redactServiceRelationSecrets({ + ...record, + metadata: redactBackupMetadataSecrets(record.metadata), + }) as T; +}; + export const redactProjectNestedSecrets = < T extends SecretRecord | null | undefined, >(