Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
213 changes: 213 additions & 0 deletions apps/dokploy/__test__/security/rollback-context-exposure.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, boolean> } };
}) => {
const rollbackConfig = args.with?.rollback;
const rollback: Record<string, unknown> = 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);
});
});
15 changes: 0 additions & 15 deletions apps/dokploy/__test__/security/secret-redaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
redactDeployableServiceSecrets,
redactDeployableServiceSecretsFor,
redactProjectNestedSecrets,
redactRollbackFullContextSecrets,
redactSecretFields,
redactSensitiveText,
secretUpdateValue,
Expand Down Expand Up @@ -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",
Expand Down
20 changes: 6 additions & 14 deletions apps/dokploy/server/api/routers/deployment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 3 additions & 1 deletion apps/dokploy/server/api/routers/rollbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 0 additions & 17 deletions packages/server/src/utils/security/redaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,23 +325,6 @@ export const redactProjectNestedSecrets = <
return redactedProject as T;
};

export const redactRollbackFullContextSecrets = <T>(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;
Expand Down
Loading