diff --git a/apps/dokploy/__test__/backup/restore-security.test.ts b/apps/dokploy/__test__/backup/restore-security.test.ts index 9a4bcfa680a..0b56421162b 100644 --- a/apps/dokploy/__test__/backup/restore-security.test.ts +++ b/apps/dokploy/__test__/backup/restore-security.test.ts @@ -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\\`"); diff --git a/apps/dokploy/__test__/compose/compose-file-preview-secrets.test.ts b/apps/dokploy/__test__/compose/compose-file-preview-secrets.test.ts new file mode 100644 index 00000000000..942c1731e5f --- /dev/null +++ b/apps/dokploy/__test__/compose/compose-file-preview-secrets.test.ts @@ -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[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"); + }); + } +}); diff --git a/apps/dokploy/__test__/database/change-password-command-boundary.test.ts b/apps/dokploy/__test__/database/change-password-command-boundary.test.ts index aef07247ce9..3bba5b766f6 100644 --- a/apps/dokploy/__test__/database/change-password-command-boundary.test.ts +++ b/apps/dokploy/__test__/database/change-password-command-boundary.test.ts @@ -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}'@'%'`); }); @@ -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}'@'%'`); }); diff --git a/apps/dokploy/__test__/environment/environment-secret-boundary.test.ts b/apps/dokploy/__test__/environment/environment-secret-boundary.test.ts index 7621e39fa85..0ba0748b36c 100644 --- a/apps/dokploy/__test__/environment/environment-secret-boundary.test.ts +++ b/apps/dokploy/__test__/environment/environment-secret-boundary.test.ts @@ -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(), @@ -16,6 +17,7 @@ const serverMocks = vi.hoisted(() => ({ findProjectById: vi.fn(), findRedisById: vi.fn(), getAccessibleServerIds: vi.fn(), + updateEnvironmentById: vi.fn(), })); const permissionMocks = vi.hoisted(() => ({ @@ -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, @@ -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", () => ({ @@ -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")) diff --git a/apps/dokploy/__test__/placement/placement-ownership-boundary.test.ts b/apps/dokploy/__test__/placement/placement-ownership-boundary.test.ts index 81cd12ad669..97a66c3682f 100644 --- a/apps/dokploy/__test__/placement/placement-ownership-boundary.test.ts +++ b/apps/dokploy/__test__/placement/placement-ownership-boundary.test.ts @@ -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(() => ({ @@ -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", () => ({ diff --git a/apps/dokploy/__test__/security/database-password-command-redaction.test.ts b/apps/dokploy/__test__/security/database-password-command-redaction.test.ts new file mode 100644 index 00000000000..957fa4a7e14 --- /dev/null +++ b/apps/dokploy/__test__/security/database-password-command-redaction.test.ts @@ -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;`, + ]); + }); + } +}); diff --git a/apps/dokploy/__test__/security/project-env-response-redaction.test.ts b/apps/dokploy/__test__/security/project-env-response-redaction.test.ts new file mode 100644 index 00000000000..af893d88d09 --- /dev/null +++ b/apps/dokploy/__test__/security/project-env-response-redaction.test.ts @@ -0,0 +1,158 @@ +import { REDACTED_SECRET_VALUE } from "@dokploy/server/utils/security/redaction"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const serverMocks = vi.hoisted(() => ({ + deleteProject: vi.fn(), + findEnvironmentById: vi.fn(), + findProjectById: vi.fn(), + updateProjectById: vi.fn(), +})); + +const permissionMocks = vi.hoisted(() => ({ + checkPermission: vi.fn(), + checkProjectAccess: vi.fn(), + findMemberByUserId: vi.fn(), + hasPermission: vi.fn(), +})); + +vi.mock("@dokploy/server", () => ({ + IS_CLOUD: false, + deleteProject: serverMocks.deleteProject, + findApplicationById: vi.fn(), + findComposeById: vi.fn(), + findEnvironmentById: serverMocks.findEnvironmentById, + findLibsqlById: vi.fn(), + findMariadbById: vi.fn(), + findMongoById: vi.fn(), + findMySqlById: vi.fn(), + findPostgresById: vi.fn(), + findProjectById: serverMocks.findProjectById, + findRedisById: vi.fn(), + getAccessibleServerIds: vi.fn(), + updateProjectById: serverMocks.updateProjectById, +})); + +vi.mock("@dokploy/server/db", () => ({ db: {} })); + +vi.mock("@dokploy/server/services/permission", () => ({ + addNewEnvironment: vi.fn(), + addNewProject: vi.fn(), + checkPermission: permissionMocks.checkPermission, + checkProjectAccess: permissionMocks.checkProjectAccess, + findMemberByUserId: permissionMocks.findMemberByUserId, + hasPermission: permissionMocks.hasPermission, +})); + +vi.mock("@/server/api/utils/audit", () => ({ + audit: vi.fn(), +})); + +const { projectRouter } = await import("../../server/api/routers/project"); + +const caller = projectRouter.createCaller({ + db: {}, + req: {}, + res: {}, + session: { + id: "session-1", + userId: "user-1", + activeOrganizationId: "org-1", + }, + user: { id: "user-1", ownerId: "owner-1", role: "member" }, +} as never); + +const projectRow = () => ({ + projectId: "project-1", + organizationId: "org-1", + name: "project", + env: "PROJECT_SECRET=secret", +}); + +const sourceEnvironment = () => ({ + environmentId: "env-1", + name: "production", + projectId: "project-1", + env: "ENV_SECRET=secret", + applications: [], + compose: [], + libsql: [], + mariadb: [], + mongo: [], + mysql: [], + postgres: [], + redis: [], + project: projectRow(), +}); + +const grantEnvRead = (granted: boolean) => { + permissionMocks.hasPermission.mockResolvedValue(granted); +}; + +describe("project mutation responses", () => { + beforeEach(() => { + vi.clearAllMocks(); + permissionMocks.findMemberByUserId.mockResolvedValue({ + role: "member", + accessedProjects: ["project-1"], + accessedEnvironments: ["env-1"], + accessedServices: [], + }); + serverMocks.findProjectById.mockResolvedValue(projectRow()); + serverMocks.updateProjectById.mockResolvedValue(projectRow()); + serverMocks.deleteProject.mockResolvedValue(projectRow()); + serverMocks.findEnvironmentById.mockResolvedValue(sourceEnvironment()); + }); + + it("returns project env from update and remove only with projectEnvVars read", async () => { + grantEnvRead(false); + + const updated = await caller.update({ + projectId: "project-1", + name: "renamed", + }); + const removed = await caller.remove({ projectId: "project-1" }); + + expect(updated?.env).toBe(REDACTED_SECRET_VALUE); + expect(removed?.env).toBe(REDACTED_SECRET_VALUE); + expect(permissionMocks.hasPermission).toHaveBeenCalledWith( + expect.anything(), + { projectEnvVars: ["read"] }, + ); + + grantEnvRead(true); + + await expect( + caller.update({ projectId: "project-1", name: "renamed" }), + ).resolves.toMatchObject({ env: "PROJECT_SECRET=secret" }); + await expect( + caller.remove({ projectId: "project-1" }), + ).resolves.toMatchObject({ env: "PROJECT_SECRET=secret" }); + }); + + it("returns shared env from a same-project duplicate only with read access", async () => { + const duplicate = () => + caller.duplicate({ + sourceEnvironmentId: "env-1", + name: "copy", + includeServices: false, + duplicateInSameProject: true, + }); + grantEnvRead(false); + + const redacted = await duplicate(); + + expect(redacted).toMatchObject({ + environmentId: "env-1", + projectId: "project-1", + env: REDACTED_SECRET_VALUE, + project: { env: REDACTED_SECRET_VALUE }, + }); + + grantEnvRead(true); + + await expect(duplicate()).resolves.toMatchObject({ + env: "ENV_SECRET=secret", + project: { env: "PROJECT_SECRET=secret" }, + }); + }); +}); diff --git a/apps/dokploy/__test__/security/secret-redaction.test.ts b/apps/dokploy/__test__/security/secret-redaction.test.ts index d8b17c204d1..83a5e5ac456 100644 --- a/apps/dokploy/__test__/security/secret-redaction.test.ts +++ b/apps/dokploy/__test__/security/secret-redaction.test.ts @@ -404,6 +404,35 @@ describe("shared secret redaction helpers", () => { } }); + it("masks passwords glued to mysql/mariadb -p and shell-escaped values", () => { + const messages = [ + "docker exec -i $CONTAINER_ID sh -c 'mysql -u root -phunter2 appdb'", + "mariadb -u app -p'hunter2 x' appdb", + String.raw`mysqldump -u root -phunter2\&tail appdb`, + String.raw`mariadb-dump --user=app --password=hunter2\|tail appdb`, + String.raw`docker exec -e DOKPLOY_DB_PASSWORD=hunter2\&tail -i c sh`, + String.raw`MYSQL_PWD="hunter2\"tail" mysql -u root`, + ]; + for (const message of messages) { + const redacted = redactSensitiveText(message); + + expect(redacted).not.toContain("hunter2"); + expect(redacted).not.toContain("tail"); + expect(redacted).toContain(REDACTED_SECRET_VALUE); + } + + const untouched = [ + "docker run -p 3306:3306 mysql:8", + "ssh -p 22 root@example.com 'mysql -u root -p appdb'", + "mysql -h db -P3306 -u root appdb", + "docker compose -p mysql-stack up -d", + "mkdir -p /var/lib/mysql", + ]; + for (const message of untouched) { + expect(redactSensitiveText(message)).toBe(message); + } + }); + it("stores only redacted command output on ExecError", () => { const error = new ExecError( "Command failed: npm run build TOKEN=build-secret", diff --git a/apps/dokploy/__test__/users/user-member-listing-fields.test.ts b/apps/dokploy/__test__/users/user-member-listing-fields.test.ts new file mode 100644 index 00000000000..7d16bd209cf --- /dev/null +++ b/apps/dokploy/__test__/users/user-member-listing-fields.test.ts @@ -0,0 +1,156 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + checkPermission: vi.fn(), + memberFindFirst: vi.fn(), + memberFindMany: vi.fn(), +})); + +vi.mock("@dokploy/server", () => ({ + IS_CLOUD: false, + createApiKey: vi.fn(), + createOrganizationUserWithCredentials: vi.fn(), + findNotificationById: vi.fn(), + findOrganizationById: vi.fn(), + findServerById: vi.fn(), + findUserById: vi.fn(), + getAccessibleServerIds: vi.fn(), + getDokployUrl: vi.fn(), + getUserByToken: vi.fn(), + getWebServerSettings: vi.fn(), + removeUserById: vi.fn(), + renderInvitationEmail: vi.fn(), + sendEmailNotification: vi.fn(), + sendResendNotification: vi.fn(), + updateUser: vi.fn(), +})); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + member: { + findFirst: mocks.memberFindFirst, + findMany: mocks.memberFindMany, + }, + }, + }, +})); + +vi.mock("@dokploy/server/services/permission", () => ({ + assertRoleAssignmentAllowed: vi.fn(), + checkPermission: mocks.checkPermission, + hasPermission: vi.fn(), + resolvePermissions: vi.fn(), +})); + +vi.mock("@dokploy/server/services/proprietary/license-key", () => ({ + hasValidLicense: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/url/network", () => ({ + fetchWithPublicEgress: vi.fn(), +})); + +vi.mock("@/server/api/utils/audit", () => ({ + audit: vi.fn(), +})); + +vi.mock("@/server/api/utils/monitoring-access", () => ({ + assertContainerMetricsServiceAccess: vi.fn(), +})); + +const { userRouter } = await import("../../server/api/routers/user"); + +const ownerUserRow = { + id: "owner-1", + firstName: "Owner", + lastName: "Account", + email: "owner@example.com", + emailVerified: true, + image: null, + banned: false, + banReason: "internal note", + banExpires: null, + twoFactorEnabled: true, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + role: "user", + licenseKey: "license-key-value", + stripeCustomerId: "cus_example", + stripeSubscriptionId: "sub_example", + trustedOrigins: ["https://origin.example.com"], + serversQuantity: 3, +}; + +// Applies the relation column selection the way drizzle does, so the test sees +// what the procedure would actually return. +const memberWithUser = (query: { with?: { user?: unknown } }) => { + const selection = query.with?.user; + const columns = + selection && typeof selection === "object" && "columns" in selection + ? (selection.columns as Record) + : null; + const user = columns + ? Object.fromEntries( + Object.entries(ownerUserRow).filter(([key]) => columns[key]), + ) + : ownerUserRow; + return { + id: "member-1", + userId: "owner-1", + organizationId: "org-1", + role: "owner", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + user, + }; +}; + +const caller = userRouter.createCaller({ + db: {}, + req: {}, + res: {}, + session: { userId: "admin-1", activeOrganizationId: "org-1" }, + user: { id: "admin-1", ownerId: "owner-1", role: "admin" }, +} as never); + +const hiddenFields = [ + "licenseKey", + "stripeCustomerId", + "stripeSubscriptionId", + "trustedOrigins", + "banReason", + "banExpires", + "serversQuantity", +]; + +describe("member listings", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.checkPermission.mockResolvedValue(undefined); + mocks.memberFindMany.mockImplementation(async (query) => [ + memberWithUser(query), + ]); + mocks.memberFindFirst.mockImplementation(async (query) => + memberWithUser(query), + ); + }); + + it("returns only the user profile fields the members UI reads", async () => { + const [listed] = await caller.all(); + const single = await caller.one({ userId: "owner-1" }); + + for (const result of [listed, single]) { + expect(result?.user).toMatchObject({ + id: "owner-1", + email: "owner@example.com", + firstName: "Owner", + lastName: "Account", + banned: false, + twoFactorEnabled: true, + }); + for (const field of hiddenFields) { + expect(result?.user).not.toHaveProperty(field); + } + } + }); +}); diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts index 1669b4f3652..a3395058a62 100644 --- a/apps/dokploy/server/api/routers/compose.ts +++ b/apps/dokploy/server/api/routers/compose.ts @@ -62,6 +62,7 @@ import { preserveSecretPlaceholderFields, redactDeployableServiceSecretsFor, redactSecretFields, + redactSecretValue, type SharedEnvReadAccess, } from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; @@ -587,7 +588,10 @@ export const composeRouter = createTRPCRouter({ resourceId: input.composeId, resourceName: compose.name, }); - return result; + // Compose files often inline secrets; compose.one hides composeFile + // from callers without envVars.read the same way. + const canReadEnvVars = await hasPermission(ctx, { envVars: ["read"] }); + return canReadEnvVars ? result : redactSecretValue(result); }), isolatedDeployment: protectedProcedure .input(apiRandomizeCompose) @@ -606,7 +610,8 @@ export const composeRouter = createTRPCRouter({ resourceId: input.composeId, resourceName: compose.name, }); - return result; + const canReadEnvVars = await hasPermission(ctx, { envVars: ["read"] }); + return canReadEnvVars ? result : redactSecretValue(result); }), getConvertedCompose: protectedProcedure .input(apiFindCompose) @@ -617,9 +622,11 @@ export const composeRouter = createTRPCRouter({ const compose = await findComposeById(input.composeId); const domains = await findDomainsByComposeId(input.composeId); const composeFile = await addDomainToCompose(compose, domains); - return stringify(composeFile, { + const converted = stringify(composeFile, { lineWidth: 1000, }); + const canReadEnvVars = await hasPermission(ctx, { envVars: ["read"] }); + return canReadEnvVars ? converted : redactSecretValue(converted); }), deploy: protectedProcedure diff --git a/apps/dokploy/server/api/routers/environment.ts b/apps/dokploy/server/api/routers/environment.ts index 1345ed0e2dd..d1ce84aa97b 100644 --- a/apps/dokploy/server/api/routers/environment.ts +++ b/apps/dokploy/server/api/routers/environment.ts @@ -397,7 +397,9 @@ export const environmentRouter = createTRPCRouter({ resourceId: deletedEnvironment?.environmentId, resourceName: deletedEnvironment?.name, }); - return deletedEnvironment; + return (await canReadEnvironmentEnvVars(ctx)) + ? deletedEnvironment + : redactEnvironmentEnv(deletedEnvironment); } catch (error) { if (error instanceof TRPCError) { throw error; @@ -470,7 +472,9 @@ export const environmentRouter = createTRPCRouter({ resourceName: environment.name, }); } - return environment; + return (await canReadEnvironmentEnvVars(ctx)) + ? environment + : redactEnvironmentEnv(environment); } catch (error) { throw new TRPCError({ code: "BAD_REQUEST", diff --git a/apps/dokploy/server/api/routers/project.ts b/apps/dokploy/server/api/routers/project.ts index b5929e087dd..67c5630db16 100644 --- a/apps/dokploy/server/api/routers/project.ts +++ b/apps/dokploy/server/api/routers/project.ts @@ -42,6 +42,7 @@ import { import { serviceColumns } from "@dokploy/server/services/project"; import { preserveSecretPlaceholderFields, + redactEnvironmentSharedEnvFor, redactProjectNestedSecrets, redactSecretFields, } from "@dokploy/server/utils/security/redaction"; @@ -59,6 +60,7 @@ import { assertServicePlacementAccess, assertTargetEnvironmentAccess, } from "@/server/api/utils/placement-access"; +import { getSharedEnvReadAccess } from "@/server/api/utils/service-environment"; import { apiCreateProject, apiFindOneProject, @@ -805,7 +807,9 @@ export const projectRouter = createTRPCRouter({ resourceId: currentProject.projectId, resourceName: currentProject.name, }); - return deletedProject; + return (await canReadProjectEnvVars(ctx)) + ? deletedProject + : redactSecretFields(deletedProject, ["env"]); } catch (error) { throw error; } @@ -856,7 +860,9 @@ export const projectRouter = createTRPCRouter({ resourceName: project.name, }); } - return project; + return (await canReadProjectEnvVars(ctx)) + ? project + : redactSecretFields(project, ["env"]); } catch (error) { throw error; } @@ -1270,7 +1276,12 @@ export const projectRouter = createTRPCRouter({ resourceName: input.name, metadata: { duplicatedFrom: input.sourceEnvironmentId }, }); - return targetProject; + // With duplicateInSameProject this is the source environment, + // loaded with its env and project.env. + return redactEnvironmentSharedEnvFor( + targetProject, + await getSharedEnvReadAccess(ctx), + ); } catch (error) { if (error instanceof TRPCError) { throw error; diff --git a/apps/dokploy/server/api/routers/user.ts b/apps/dokploy/server/api/routers/user.ts index 76bf5bd7532..03c9b8b4eae 100644 --- a/apps/dokploy/server/api/routers/user.ts +++ b/apps/dokploy/server/api/routers/user.ts @@ -285,12 +285,26 @@ const assertCanDeleteOwnAccount = async (ctx: { } }; +// Member listings only need profile fields; the user row also holds billing, +// license and trusted-origin settings of the account owner. +const memberUserColumns = { + id: true, + firstName: true, + lastName: true, + email: true, + emailVerified: true, + image: true, + banned: true, + twoFactorEnabled: true, + createdAt: true, +} as const; + export const userRouter = createTRPCRouter({ all: withPermission("member", "read").query(async ({ ctx }) => { return await db.query.member.findMany({ where: eq(member.organizationId, ctx.session.activeOrganizationId), with: { - user: true, + user: { columns: memberUserColumns }, }, orderBy: [asc(member.createdAt)], }); @@ -308,7 +322,7 @@ export const userRouter = createTRPCRouter({ eq(member.organizationId, ctx.session?.activeOrganizationId || ""), ), with: { - user: true, + user: { columns: memberUserColumns }, }, }); diff --git a/apps/dokploy/server/api/utils/database-password.ts b/apps/dokploy/server/api/utils/database-password.ts index 161b3e1407c..dbd0e50cbef 100644 --- a/apps/dokploy/server/api/utils/database-password.ts +++ b/apps/dokploy/server/api/utils/database-password.ts @@ -45,7 +45,10 @@ export const buildMysqlPasswordChangeCommand = ({ }) => { const safeTargetUser = assertSafeDatabaseIdentifier(targetUser); const sql = `ALTER USER ${quoteSqlLiteral(safeTargetUser)}@'%' IDENTIFIED BY ${quoteSqlLiteral(password)}; FLUSH PRIVILEGES;`; - return `docker exec "$CONTAINER_ID" ${client} -u root ${quoteShellArg(`-p${databaseRootPassword}`)} -e ${quoteShellArg(sql)}`; + // A failed command's text is returned in the error; there the root password + // only appears as an env assignment, which redactSensitiveText masks. + const clientCommand = `${client} -u root -p"$DOKPLOY_DB_PASSWORD" -e ${quoteShellArg(sql)}`; + return `docker exec -e DOKPLOY_DB_PASSWORD=${quoteShellArg(databaseRootPassword)} "$CONTAINER_ID" sh -c ${quoteShellArg(clientCommand)}`; }; export const buildRedisPasswordChangeCommand = ({ diff --git a/packages/server/src/utils/restore/utils.ts b/packages/server/src/utils/restore/utils.ts index 73c18d23180..5bdf32d2c7e 100644 --- a/packages/server/src/utils/restore/utils.ts +++ b/packages/server/src/utils/restore/utils.ts @@ -13,6 +13,17 @@ const getDockerExecShellCommand = (command: string) => { return `docker exec -i $CONTAINER_ID sh -c ${quoteRestoreShellArg(command)}`; }; +const RESTORE_PASSWORD_ENV = "DOKPLOY_DB_PASSWORD"; + +// A failed restore streams its command text to the UI. There the password only +// appears as an env assignment, which redactSensitiveText reliably masks. +const getDockerExecShellCommandWithPassword = ( + command: string, + databasePassword: string, +) => { + return `docker exec -e ${RESTORE_PASSWORD_ENV}=${quoteRestoreShellArg(databasePassword)} -i $CONTAINER_ID sh -c ${quoteRestoreShellArg(command)}`; +}; + // User-controlled values are passed to the container via `docker exec -e` and // read as "$VAR" inside a single-quoted inner script, so they never enter the // inner command text. See the matching note in backups/utils.ts. @@ -32,8 +43,9 @@ export const getMariadbRestoreCommand = ( databasePassword: string, ) => { const safeDatabase = normalizeRestoreDatabaseName(database); - return getDockerExecShellCommand( - `mariadb -u ${quoteRestoreShellArg(databaseUser)} -p${quoteRestoreShellArg(databasePassword)} ${quoteRestoreShellArg(safeDatabase)}`, + return getDockerExecShellCommandWithPassword( + `mariadb -u ${quoteRestoreShellArg(databaseUser)} -p"$${RESTORE_PASSWORD_ENV}" ${quoteRestoreShellArg(safeDatabase)}`, + databasePassword, ); }; @@ -42,8 +54,9 @@ export const getMysqlRestoreCommand = ( databasePassword: string, ) => { const safeDatabase = normalizeRestoreDatabaseName(database); - return getDockerExecShellCommand( - `mysql -u root -p${quoteRestoreShellArg(databasePassword)} ${quoteRestoreShellArg(safeDatabase)}`, + return getDockerExecShellCommandWithPassword( + `mysql -u root -p"$${RESTORE_PASSWORD_ENV}" ${quoteRestoreShellArg(safeDatabase)}`, + databasePassword, ); }; diff --git a/packages/server/src/utils/security/redaction.ts b/packages/server/src/utils/security/redaction.ts index 11b16e277ce..d342870ecab 100644 --- a/packages/server/src/utils/security/redaction.ts +++ b/packages/server/src/utils/security/redaction.ts @@ -6,6 +6,11 @@ export type SecretRecord = Record; const SENSITIVE_KEY_PATTERN = "(?:access[_-]?key|api[_-]?key|authorization|credential|private[_-]?key|refresh[_-]?token|secret|token|password|passwd|pwd)"; +// shell-quote leaves values bare with backslash escapes (pa\&ss) or wraps them +// in double quotes with escaped quotes, so a value ends at the first unescaped +// separator, not at the first separator character. +const SHELL_VALUE_PATTERN = String.raw`"(?:\\.|[^"\\])*"|'[^']*'|(?:\\.|[^\s;&|\\])+`; + export const isRedactedSecretValue = (value: unknown) => value === REDACTED_SECRET_VALUE; @@ -84,33 +89,53 @@ const NO_SHARED_ENV_READ_ACCESS: SharedEnvReadAccess = { projectEnv: false, }; -// Service reads load environment.project for organization checks. The env of -// those relations is shared by every service in them, so it is only returned to -// callers with the matching environmentEnvVars/projectEnvVars read permission. -const redactServiceSharedEnv = ( - record: T, +// The env of an environment and its project is shared by every service in them, +// so it is only returned to callers with the matching +// environmentEnvVars/projectEnvVars read permission. +export const redactEnvironmentSharedEnvFor = < + T extends SecretRecord | null | undefined, +>( + environment: T, access: SharedEnvReadAccess, ) => { - if (!record?.environment || typeof record.environment !== "object") { - return record; + if (!environment) { + return environment; } - let environment = record.environment as SecretRecord; + let redacted: SecretRecord = environment; if (!access.environmentEnv) { - environment = redactSecretFields(environment, ["env"]); + redacted = redactSecretFields(redacted, ["env"]); } if ( !access.projectEnv && - environment.project && - typeof environment.project === "object" + redacted.project && + typeof redacted.project === "object" ) { - environment = { - ...environment, - project: redactSecretFields(environment.project as SecretRecord, ["env"]), + redacted = { + ...redacted, + project: redactSecretFields(redacted.project as SecretRecord, ["env"]), }; } - return { ...record, environment } as T; + return redacted as T; +}; + +// Service reads load environment.project for organization checks. +const redactServiceSharedEnv = ( + record: T, + access: SharedEnvReadAccess, +) => { + if (!record?.environment || typeof record.environment !== "object") { + return record; + } + + return { + ...record, + environment: redactEnvironmentSharedEnvFor( + record.environment as SecretRecord, + access, + ), + } as T; }; export const redactDeployableServiceSecrets = < @@ -374,7 +399,7 @@ export function redactSensitiveText(value: string | null | undefined) { redacted = redacted.replace( new RegExp( - `(\\b[A-Z0-9_]*${SENSITIVE_KEY_PATTERN}[A-Z0-9_]*=)("[^"]*"|'[^']*'|[^\\s;&|]+)`, + `(\\b[A-Z0-9_]*${SENSITIVE_KEY_PATTERN}[A-Z0-9_]*=)(${SHELL_VALUE_PATTERN})`, "gi", ), `$1${REDACTED_SECRET_VALUE}`, @@ -397,12 +422,23 @@ export function redactSensitiveText(value: string | null | undefined) { // after them is a positional argument (the database name), not a secret. redacted = redacted.replace( new RegExp( - `(\\s(?!--?no-)--?[a-z0-9-]*${SENSITIVE_KEY_PATTERN}[a-z0-9-]*(?:=|\\s+))("[^"]*"|'[^']*'|[^\\s;&|]+)`, + `(\\s(?!--?no-)--?[a-z0-9-]*${SENSITIVE_KEY_PATTERN}[a-z0-9-]*(?:=|\\s+))(${SHELL_VALUE_PATTERN})`, "gi", ), `$1${REDACTED_SECRET_VALUE}`, ); + // mysql/mariadb clients take the password glued to -p. A separate word after + // -p is not a password, uppercase -P is the port, and -p of other tools + // (docker -p, ssh -p, compose -p) is a port or project name. + redacted = redacted.replace( + new RegExp( + String.raw`(\b(?:mysql|mariadb)[a-z-]*(?:\\.|[^\n;&|\\])*?\s-p)(${SHELL_VALUE_PATTERN})`, + "g", + ), + `$1${REDACTED_SECRET_VALUE}`, + ); + redacted = redacted.replace( /(\b(?