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
4 changes: 3 additions & 1 deletion apps/dokploy/__test__/backup/restore-security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1170,7 +1170,9 @@ describe("backup restore command safety", () => {
type: "mariadb",
});

expect(command).toContain("docker exec -i $CONTAINER_ID sh -c ");
expect(command).toContain(
"docker exec -e DOKPLOY_DB_PASSWORD=\\`id\\` -i $CONTAINER_ID sh -c ",
);
expect(command).not.toContain('sh -c "mariadb');
expect(command).toContain("\\$\\(id\\)");
expect(command).toContain("\\`id\\`");
Expand Down
93 changes: 93 additions & 0 deletions apps/dokploy/__test__/compose/compose-file-preview-secrets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { REDACTED_SECRET_VALUE } from "@dokploy/server/utils/security/redaction";
import { beforeEach, describe, expect, it, vi } from "vitest";

const COMPOSE_FILE = `services:
app:
image: app
environment:
DB_PASSWORD: compose-inline-secret
`;

const mocks = vi.hoisted(() => ({
hasPermission: vi.fn(),
}));

vi.mock("@dokploy/server/services/permission", () => ({
checkServicePermissionAndAccess: vi.fn(async () => undefined),
hasPermission: mocks.hasPermission,
}));

vi.mock("@dokploy/server", () => ({
IS_CLOUD: false,
addDomainToCompose: vi.fn(async () => ({
services: {
app: {
image: "app",
environment: { DB_PASSWORD: "compose-inline-secret" },
},
},
})),
findComposeById: vi.fn(async () => ({
composeId: "compose-1",
name: "example-compose",
})),
findDomainsByComposeId: vi.fn(async () => []),
findApplicationById: vi.fn(),
findLibsqlById: vi.fn(),
findMariadbById: vi.fn(),
findMongoById: vi.fn(),
findMySqlById: vi.fn(),
findPostgresById: vi.fn(),
findRedisById: vi.fn(),
randomizeComposeFile: vi.fn(async () => COMPOSE_FILE),
randomizeIsolatedDeploymentComposeFile: vi.fn(async () => COMPOSE_FILE),
}));

vi.mock("@dokploy/server/lib/auth", () => ({
validateRequest: vi.fn(),
}));

vi.mock("@/server/api/utils/audit", () => ({
audit: vi.fn(async () => undefined),
}));

vi.mock("@/server/queues/queueSetup", () => ({
cleanQueuesByCompose: vi.fn(),
killDockerBuild: vi.fn(),
myQueue: { add: vi.fn() },
}));

const { composeRouter } = await import("@/server/api/routers/compose");

const caller = composeRouter.createCaller({
session: { activeOrganizationId: "org-1", userId: "user-1" },
user: { id: "user-1", email: "member@example.com", role: "member" },
} as Parameters<typeof composeRouter.createCaller>[0]);

const previews = {
randomizeCompose: () =>
caller.randomizeCompose({ composeId: "compose-1", suffix: "x" }),
isolatedDeployment: () =>
caller.isolatedDeployment({ composeId: "compose-1", suffix: "x" }),
getConvertedCompose: () =>
caller.getConvertedCompose({ composeId: "compose-1" }),
};

describe("compose file previews", () => {
beforeEach(() => {
vi.clearAllMocks();
});

for (const [name, run] of Object.entries(previews)) {
it(`${name} returns the compose file only to callers with envVars.read`, async () => {
mocks.hasPermission.mockResolvedValue(false);
expect(await run()).toBe(REDACTED_SECRET_VALUE);
expect(mocks.hasPermission).toHaveBeenCalledWith(expect.anything(), {
envVars: ["read"],
});

mocks.hasPermission.mockResolvedValue(true);
expect(await run()).toContain("compose-inline-secret");
});
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -278,9 +278,11 @@ describe("database change-password command boundary", () => {
const mysqlSql =
"ALTER USER 'dokploy_user'@'%' IDENTIFIED BY 'safePassword123'; FLUSH PRIVILEGES;";
expect(command).toContain(
`mysql -u root ${quoteShellArg(`-p${databaseRootPassword}`)} -e`,
`docker exec -e DOKPLOY_DB_PASSWORD=${quoteShellArg(databaseRootPassword)} "$CONTAINER_ID" sh -c ${quoteShellArg(
`mysql -u root -p"$DOKPLOY_DB_PASSWORD" -e ${quoteShellArg(mysqlSql)}`,
)}`,
);
expect(command).toContain(quoteShellArg(mysqlSql));
expect(command).not.toContain(`-p${databaseRootPassword}`);
expect(command).not.toContain(`-p'${databaseRootPassword}'`);
expect(command).not.toContain(`ALTER USER '${databaseUser}'@'%'`);
});
Expand All @@ -307,9 +309,11 @@ describe("database change-password command boundary", () => {
const mariadbSql =
"ALTER USER 'dokploy_user'@'%' IDENTIFIED BY 'safePassword123'; FLUSH PRIVILEGES;";
expect(command).toContain(
`mariadb -u root ${quoteShellArg(`-p${databaseRootPassword}`)} -e`,
`docker exec -e DOKPLOY_DB_PASSWORD=${quoteShellArg(databaseRootPassword)} "$CONTAINER_ID" sh -c ${quoteShellArg(
`mariadb -u root -p"$DOKPLOY_DB_PASSWORD" -e ${quoteShellArg(mariadbSql)}`,
)}`,
);
expect(command).toContain(quoteShellArg(mariadbSql));
expect(command).not.toContain(`-p${databaseRootPassword}`);
expect(command).not.toContain(`-p'${databaseRootPassword}'`);
expect(command).not.toContain(`ALTER USER '${databaseUser}'@'%'`);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { TRPCError } from "@trpc/server";
import { beforeEach, describe, expect, it, vi } from "vitest";

const serverMocks = vi.hoisted(() => ({
deleteEnvironment: vi.fn(),
duplicateEnvironment: vi.fn(),
findApplicationById: vi.fn(),
findComposeById: vi.fn(),
Expand All @@ -16,6 +17,7 @@ const serverMocks = vi.hoisted(() => ({
findProjectById: vi.fn(),
findRedisById: vi.fn(),
getAccessibleServerIds: vi.fn(),
updateEnvironmentById: vi.fn(),
}));

const permissionMocks = vi.hoisted(() => ({
Expand All @@ -37,7 +39,7 @@ const dbMocks = vi.hoisted(() => ({

vi.mock("@dokploy/server", () => ({
createEnvironment: vi.fn(),
deleteEnvironment: vi.fn(),
deleteEnvironment: serverMocks.deleteEnvironment,
duplicateEnvironment: serverMocks.duplicateEnvironment,
findApplicationById: serverMocks.findApplicationById,
findComposeById: serverMocks.findComposeById,
Expand All @@ -51,7 +53,7 @@ vi.mock("@dokploy/server", () => ({
findProjectById: serverMocks.findProjectById,
findRedisById: serverMocks.findRedisById,
getAccessibleServerIds: serverMocks.getAccessibleServerIds,
updateEnvironmentById: vi.fn(),
updateEnvironmentById: serverMocks.updateEnvironmentById,
}));

vi.mock("@dokploy/server/db", () => ({
Expand Down Expand Up @@ -247,6 +249,39 @@ describe("environment secret boundary", () => {
expect(result.env).toBe("ENV_SECRET=secret");
});

it("returns environment env from update and remove only with environmentEnvVars read", async () => {
const row = {
environmentId: "env-1",
name: "renamed",
projectId: "project-1",
env: "ENV_SECRET=secret",
};
serverMocks.updateEnvironmentById.mockResolvedValue(row);
serverMocks.deleteEnvironment.mockResolvedValue(row);
const caller = environmentRouter.createCaller(createContext("member"));

const updated = await caller.update({
environmentId: "env-1",
name: "renamed",
});
const removed = await caller.remove({ environmentId: "env-1" });

expect(updated?.env).toBe(REDACTED_SECRET_VALUE);
expect(removed?.env).toBe(REDACTED_SECRET_VALUE);
expect(permissionMocks.hasPermission).toHaveBeenCalledWith(
expect.anything(),
{ environmentEnvVars: ["read"] },
);

permissionMocks.hasPermission.mockResolvedValue(true);
await expect(
caller.update({ environmentId: "env-1", name: "renamed" }),
).resolves.toMatchObject({ env: "ENV_SECRET=secret" });
await expect(
caller.remove({ environmentId: "env-1" }),
).resolves.toMatchObject({ env: "ENV_SECRET=secret" });
});

it("redacts environment env from search results without environmentEnvVars read", async () => {
const result = await environmentRouter
.createCaller(createContext("member"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ const permissionMocks = vi.hoisted(() => ({
checkServiceAccess: vi.fn(),
checkServicePermissionAndAccess: vi.fn(),
findMemberByUserId: vi.fn(),
hasPermission: vi.fn(async () => true),
}));

const dbMocks = vi.hoisted(() => ({
Expand Down Expand Up @@ -169,6 +170,7 @@ vi.mock("@dokploy/server/services/permission", () => ({
checkServicePermissionAndAccess:
permissionMocks.checkServicePermissionAndAccess,
findMemberByUserId: permissionMocks.findMemberByUserId,
hasPermission: permissionMocks.hasPermission,
}));

vi.mock("@dokploy/server/services/git-provider", () => ({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { execSync } from "node:child_process";
import {
chmodSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { ExecError } from "@dokploy/server/utils/process/ExecError";
import {
getMariadbRestoreCommand,
getMysqlRestoreCommand,
getRestoreCommand,
} from "@dokploy/server/utils/restore/utils";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildMysqlPasswordChangeCommand } from "../../server/api/utils/database-password";

const ROOT_PASSWORD = "Root&Secret|Pw(1)";
const NEW_PASSWORD = "New&Secret|Pw(2)";

// Mirrors how execAsync wraps a failing local command: Node puts the whole
// command line into the error message.
const failedCommandError = (command: string) =>
new ExecError(`Command execution failed: Command failed: ${command}\n`, {
command,
stderr: "ERROR 1045 (28000): Access denied",
});

let workDir: string;
let argsFile: string;

// `docker exec` stub: applies the -e pairs and runs the remaining command, so
// the test sees what the database client inside the container would receive.
beforeAll(() => {
workDir = mkdtempSync(path.join(tmpdir(), "dokploy-db-pw-"));
const binDir = path.join(workDir, "bin");
mkdirSync(binDir);
argsFile = path.join(workDir, "args");
writeFileSync(
path.join(binDir, "docker"),
`#!/bin/bash
shift
envs=()
while [ $# -gt 0 ]; do
case "$1" in
-e) envs+=("$2"); shift 2 ;;
-i) shift ;;
*) break ;;
esac
done
shift
env "\${envs[@]}" "$@"
`,
);
for (const client of ["mysql", "mariadb"]) {
writeFileSync(
path.join(binDir, client),
`#!/bin/sh\nprintf '%s\\n' "$@" > "$ARGS_FILE"\n`,
);
chmodSync(path.join(binDir, client), 0o755);
}
chmodSync(path.join(binDir, "docker"), 0o755);
});

afterAll(() => {
rmSync(workDir, { recursive: true, force: true });
});

const runWithStub = (command: string) => {
rmSync(argsFile, { force: true });
execSync(command, {
shell: "/bin/bash",
stdio: "ignore",
input: "",
env: {
NODE_ENV: "test",
PATH: `${path.join(workDir, "bin")}:/usr/bin:/bin`,
CONTAINER_ID: "container",
ARGS_FILE: argsFile,
},
});
return readFileSync(argsFile, "utf8").trimEnd().split("\n");
};

describe("mysql/mariadb restore commands", () => {
const cases = [
[
"mysql",
getMysqlRestoreCommand("appdb", ROOT_PASSWORD),
["-u", "root", `-p${ROOT_PASSWORD}`, "appdb"],
],
[
"mariadb",
getMariadbRestoreCommand("appdb", "app", ROOT_PASSWORD),
["-u", "app", `-p${ROOT_PASSWORD}`, "appdb"],
],
] as const;

for (const [client, command, expectedArgs] of cases) {
it(`passes the ${client} password to the client but not into error text`, () => {
const error = failedCommandError(command);

expect(error.message).not.toContain("Secret");
expect(error.command).not.toContain("Secret");
expect(error.message).toContain("__DOKPLOY_REDACTED_SECRET__");
expect(runWithStub(command)).toEqual(expectedArgs);
});
}

it("keeps compose restore credentials out of error text", () => {
const command = getRestoreCommand({
appName: "compose-one",
credentials: { database: "appdb", databasePassword: ROOT_PASSWORD },
rcloneCommand: "printf ''",
restoreType: "docker-compose",
serviceName: "db",
type: "mysql",
});

expect(failedCommandError(command).message).not.toContain("Secret");
});
});

describe("mysql/mariadb password change command", () => {
for (const client of ["mysql", "mariadb"] as const) {
it(`passes ${client} passwords to the client but not into error text`, () => {
const command = buildMysqlPasswordChangeCommand({
client,
databaseRootPassword: ROOT_PASSWORD,
targetUser: "app",
password: NEW_PASSWORD,
});
const error = failedCommandError(command);

expect(error.message).not.toContain("Secret");
expect(error.command).not.toContain("Secret");
expect(runWithStub(command)).toEqual([
"-u",
"root",
`-p${ROOT_PASSWORD}`,
"-e",
`ALTER USER 'app'@'%' IDENTIFIED BY '${NEW_PASSWORD}'; FLUSH PRIVILEGES;`,
]);
});
}
});
Loading
Loading