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, >(