diff --git a/apps/dokploy/__test__/security/rollback-context-exposure.test.ts b/apps/dokploy/__test__/security/rollback-context-exposure.test.ts new file mode 100644 index 00000000000..7de3989aa2d --- /dev/null +++ b/apps/dokploy/__test__/security/rollback-context-exposure.test.ts @@ -0,0 +1,213 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + audit: vi.fn(), + checkPermission: vi.fn(), + checkServicePermissionAndAccess: vi.fn(), + deploymentsFindMany: vi.fn(), + findMemberByUserId: vi.fn(), + findRollbackById: vi.fn(), + findScheduleById: vi.fn(), + removeRollbackById: vi.fn(), + rollback: vi.fn(), +})); + +vi.mock("@dokploy/server", () => ({ + IS_CLOUD: false, + findRollbackById: mocks.findRollbackById, + findScheduleById: mocks.findScheduleById, + removeRollbackById: mocks.removeRollbackById, + rollback: mocks.rollback, +})); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + deployments: { + findMany: mocks.deploymentsFindMany, + }, + }, + }, +})); + +vi.mock("@dokploy/server/services/permission", () => ({ + checkPermission: mocks.checkPermission, + checkServicePermissionAndAccess: mocks.checkServicePermissionAndAccess, + findMemberByUserId: mocks.findMemberByUserId, +})); + +vi.mock("@/server/api/utils/audit", () => ({ + audit: mocks.audit, +})); + +vi.mock("@/server/api/utils/placement-access", () => ({ + assertTargetServerAccess: vi.fn(), +})); + +vi.mock("@/server/queues/queueSetup", () => ({ + myQueue: { + add: vi.fn(), + getJobs: vi.fn(), + }, +})); + +vi.mock("@/server/utils/deploy", () => ({ + deploy: vi.fn(), + fetchDeployApiJobs: vi.fn(), + fetchDeployApiJobsResult: vi.fn(), +})); + +const { rollbackRouter } = await import("../../server/api/routers/rollbacks"); +const { deploymentRouter } = await import( + "../../server/api/routers/deployment" +); + +const ctx = { + db: {}, + req: {}, + res: {}, + session: { + userId: "user-1", + activeOrganizationId: "org-1", + }, + user: { + id: "user-1", + role: "member", + }, +} as never; + +const secrets = [ + "app-env-secret", + "build-arg-secret", + "build-secret-value", + "docker-password-secret", + "refresh-token-secret", + "git-url-secret", + "basic-auth-secret", + "mount-file-secret", + "environment-env-secret", + "project-env-secret", + "metrics-token-secret", + "registry-secret", + "build-registry-secret", + "rollback-registry-secret", +]; + +const storedRollback = () => ({ + rollbackId: "rollback-1", + deploymentId: "deployment-1", + version: 3, + image: "app:v3", + createdAt: "2026-01-01T00:00:00.000Z", + fullContext: { + applicationId: "app-1", + env: "API_KEY=app-env-secret", + buildArgs: "TOKEN=build-arg-secret", + buildSecrets: "NPM_TOKEN=build-secret-value", + password: "docker-password-secret", + refreshToken: "refresh-token-secret", + customGitUrl: "https://deploy:git-url-secret@git.example.com/repo.git", + security: [{ username: "admin", password: "basic-auth-secret" }], + mounts: [{ type: "file", content: "mount-file-secret" }], + environment: { + env: "ENV_KEY=environment-env-secret", + project: { env: "PROJECT_KEY=project-env-secret" }, + }, + server: { + metricsConfig: { server: { token: "metrics-token-secret" } }, + }, + registry: { registryId: "registry-1", password: "registry-secret" }, + buildRegistry: { + registryId: "registry-2", + password: "build-registry-secret", + }, + rollbackRegistry: { + registryId: "registry-3", + password: "rollback-registry-secret", + }, + }, +}); + +const expectNoSecrets = (value: unknown) => { + const serialized = JSON.stringify(value); + for (const secret of secrets) { + expect(serialized).not.toContain(secret); + } +}; + +// Emulates Drizzle's `columns: { name: false }` exclusion on the rollback relation. +const findManyWithRollbackColumns = async (args: { + with?: { rollback?: true | { columns?: Record } }; +}) => { + const rollbackConfig = args.with?.rollback; + const rollback: Record = storedRollback(); + if (rollbackConfig && rollbackConfig !== true) { + for (const [column, included] of Object.entries( + rollbackConfig.columns ?? {}, + )) { + if (included === false) { + delete rollback[column]; + } + } + } + + return [ + { + deploymentId: "deployment-1", + applicationId: "app-1", + status: "done", + rollbackId: "rollback-1", + rollback, + }, + ]; +}; + +describe("rollback full context exposure", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.checkServicePermissionAndAccess.mockResolvedValue(undefined); + mocks.findRollbackById.mockResolvedValue({ + ...storedRollback(), + deployment: { deploymentId: "deployment-1", applicationId: "app-1" }, + }); + mocks.removeRollbackById.mockResolvedValue({ + ...storedRollback(), + deployment: { deploymentId: "deployment-1", applicationId: "app-1" }, + }); + mocks.deploymentsFindMany.mockImplementation(findManyWithRollbackColumns); + }); + + it("does not return the stored rollback context from rollback.delete", async () => { + const result = await rollbackRouter + .createCaller(ctx) + .delete({ rollbackId: "rollback-1" }); + + expect(mocks.checkServicePermissionAndAccess).toHaveBeenCalledWith( + ctx, + "app-1", + { deployment: ["create"] }, + ); + expect(mocks.removeRollbackById).toHaveBeenCalledWith("rollback-1"); + expect(result).toMatchObject({ + rollbackId: "rollback-1", + deploymentId: "deployment-1", + image: "app:v3", + }); + expect(result).not.toHaveProperty("fullContext"); + expectNoSecrets(result); + }); + + it("does not return rollback contexts from deployment.allByType", async () => { + const result = await deploymentRouter + .createCaller(ctx) + .allByType({ id: "app-1", type: "application" }); + + expect(result).toHaveLength(1); + expect(result[0]?.rollback).toMatchObject({ + rollbackId: "rollback-1", + image: "app:v3", + }); + expect(result[0]?.rollback).not.toHaveProperty("fullContext"); + expectNoSecrets(result); + }); +}); diff --git a/apps/dokploy/__test__/security/secret-redaction.test.ts b/apps/dokploy/__test__/security/secret-redaction.test.ts index d8b17c204d1..c87d749aee9 100644 --- a/apps/dokploy/__test__/security/secret-redaction.test.ts +++ b/apps/dokploy/__test__/security/secret-redaction.test.ts @@ -9,7 +9,6 @@ import { redactDeployableServiceSecrets, redactDeployableServiceSecretsFor, redactProjectNestedSecrets, - redactRollbackFullContextSecrets, redactSecretFields, redactSensitiveText, secretUpdateValue, @@ -326,20 +325,6 @@ describe("shared secret redaction helpers", () => { }); }); - it("redacts rollback registry credentials", () => { - const redacted = redactRollbackFullContextSecrets({ - registry: { password: "registry-secret" }, - buildRegistry: { password: "build-registry-secret" }, - rollbackRegistry: { password: "rollback-registry-secret" }, - }); - - expect(redacted).toMatchObject({ - registry: { password: REDACTED_SECRET_VALUE }, - buildRegistry: { password: REDACTED_SECRET_VALUE }, - rollbackRegistry: { password: REDACTED_SECRET_VALUE }, - }); - }); - it("redacts secrets embedded in command and provider error text", () => { const message = [ "Command failed: git clone https://x-access-token:ghp_abcdefghijklmnopqrstuvwxyz@github.com/org/repo.git", diff --git a/apps/dokploy/server/api/routers/deployment.ts b/apps/dokploy/server/api/routers/deployment.ts index 2d2623a2c1a..50f156d4184 100644 --- a/apps/dokploy/server/api/routers/deployment.ts +++ b/apps/dokploy/server/api/routers/deployment.ts @@ -22,7 +22,6 @@ import { checkServicePermissionAndAccess, findMemberByUserId, } from "@dokploy/server/services/permission"; -import { redactRollbackFullContextSecrets } from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { desc, eq } from "drizzle-orm"; import { z } from "zod"; @@ -406,24 +405,17 @@ export const deploymentRouter = createTRPCRouter({ deployment: ["read"], }); } - const deploymentsList = await db.query.deployments.findMany({ + return await db.query.deployments.findMany({ where: eq(deployments[`${input.type}Id`], input.id), orderBy: desc(deployments.createdAt), with: { - rollback: true, + rollback: { + columns: { + fullContext: false, + }, + }, }, }); - return deploymentsList.map((deployment) => ({ - ...deployment, - rollback: deployment.rollback - ? { - ...deployment.rollback, - fullContext: redactRollbackFullContextSecrets( - deployment.rollback.fullContext, - ), - } - : deployment.rollback, - })); }), killProcess: protectedProcedure .input( diff --git a/apps/dokploy/server/api/routers/rollbacks.ts b/apps/dokploy/server/api/routers/rollbacks.ts index 20c64125818..ff3501ccd8a 100644 --- a/apps/dokploy/server/api/routers/rollbacks.ts +++ b/apps/dokploy/server/api/routers/rollbacks.ts @@ -21,7 +21,9 @@ export const rollbackRouter = createTRPCRouter({ deployment: ["create"], }); } - const result = await removeRollbackById(input.rollbackId); + const { fullContext: _, ...result } = await removeRollbackById( + input.rollbackId, + ); await audit(ctx, { action: "delete", resourceType: "deployment", diff --git a/packages/server/src/utils/security/redaction.ts b/packages/server/src/utils/security/redaction.ts index 11b16e277ce..66310acf78a 100644 --- a/packages/server/src/utils/security/redaction.ts +++ b/packages/server/src/utils/security/redaction.ts @@ -325,23 +325,6 @@ export const redactProjectNestedSecrets = < return redactedProject as T; }; -export const redactRollbackFullContextSecrets = (fullContext: T): T => { - if (!fullContext || typeof fullContext !== "object") { - return fullContext; - } - - const redacted = { ...(fullContext as SecretRecord) }; - for (const key of ["registry", "buildRegistry", "rollbackRegistry"]) { - if (redacted[key] && typeof redacted[key] === "object") { - redacted[key] = redactSecretFields(redacted[key] as SecretRecord, [ - "password", - ]); - } - } - - return redacted as T; -}; - export function redactSensitiveText(value: string): string; export function redactSensitiveText(value: null): null; export function redactSensitiveText(value: undefined): undefined;