From 149186397929277f7cb542fe883d42eb368896c7 Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:53:33 -0400 Subject: [PATCH 1/4] fix: keep mysql and mariadb passwords out of restore and password-change errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Что: - Restore mysql/mariadb и changePassword для mysql/mariadb передают пароль в контейнер через `docker exec -e DOKPLOY_DB_PASSWORD=...`, клиент читает его как `-p"$DOKPLOY_DB_PASSWORD"`. - `redactSensitiveText` маскирует пароль, приклеенный к `-p` после вызова mysql/mariadb-клиентов (`-p` с пробелом, `-P` порт и `-p` других утилит не трогаются). - Значения в env-присваиваниях и `--password` флагах маскируются целиком, даже если shell-quote экранировал в них `&`, `|` или кавычки. Зачем: - При локальном restore текст упавшей команды попадает в `Error: ...`, который стримит `backup.restoreBackupWithLogs`, а форма `-p<пароль>` не маскировалась. То же касалось ошибок changePassword (root-пароль). - Пароли с `&` или `|` (разрешены схемой) раньше маскировались только до этого символа. Риски: - Restore и смена пароля mysql/mariadb теперь вызывают клиента через `sh -c` внутри контейнера; в официальных образах mysql/mariadb `sh` есть. В логах вместо `-p<пароль>` теперь видно `DOKPLOY_DB_PASSWORD=__DOKPLOY_REDACTED_SECRET__`. - Новое правило маскирует `-p<значение>` после слов mysql*/mariadb* в той же строке, редкий не-парольный `-pXXX` после них тоже будет скрыт. Проверки: - Команды и результаты: `CI=true bunx vitest run --config __test__/vitest.config.ts` по новым/изменённым тестам (database-password-command-redaction, secret-redaction, change-password-command-boundary, restore-security) - passed; эти тесты падают на исходном коде (проверено откатом исходников к origin/AgentHits-Dev). `bun run typecheck` - exit 0; `bunx biome check ` - 0 ошибок. - Ограничения: реальный restore и смена пароля в живом mysql/mariadb-контейнере не запускались; вызов клиента проверен stub-ом `docker`, который применяет `-e` и запускает внутренний `sh -c`. What: - mysql/mariadb restore and mysql/mariadb changePassword pass the password to the container via `docker exec -e DOKPLOY_DB_PASSWORD=...`; the client reads it as `-p"$DOKPLOY_DB_PASSWORD"`. - `redactSensitiveText` masks a password glued to `-p` after mysql/mariadb client invocations (`-p` followed by a space, `-P` port and other tools' `-p` are left alone). - Values in env assignments and `--password` flags are masked whole even when shell-quote escaped `&`, `|` or quotes in them. Why: - On local restores the failed command text ends up in the `Error: ...` line streamed by `backup.restoreBackupWithLogs`, and the `-p` form was not masked. changePassword errors had the same problem with the root password. - Passwords with `&` or `|` (allowed by the schema) were masked only up to that character. Risks: - mysql/mariadb restore and password change now run the client through `sh -c` inside the container; official mysql/mariadb images ship `sh`. Logs now show `DOKPLOY_DB_PASSWORD=__DOKPLOY_REDACTED_SECRET__` instead of `-p`. - The new rule masks `-p` after mysql*/mariadb* words on the same line, so a rare non-password `-pXXX` after them is hidden too. Checks: - Commands and results: `CI=true bunx vitest run --config __test__/vitest.config.ts` on the new/changed tests (database-password-command-redaction, secret-redaction, change-password-command-boundary, restore-security) - passed; these tests fail on the original code (checked by reverting the sources to origin/AgentHits-Dev). `bun run typecheck` - exit 0; `bunx biome check ` - 0 errors. - Limitations: no real restore or password change against a live mysql/mariadb container; the client invocation is checked with a `docker` stub that applies `-e` and runs the inner `sh -c`. Co-Authored-By: Claude Opus 5.5 --- .../__test__/backup/restore-security.test.ts | 4 +- .../change-password-command-boundary.test.ts | 12 +- ...atabase-password-command-redaction.test.ts | 149 ++++++++++++++++++ .../security/secret-redaction.test.ts | 29 ++++ .../server/api/utils/database-password.ts | 5 +- packages/server/src/utils/restore/utils.ts | 21 ++- .../server/src/utils/security/redaction.ts | 20 ++- 7 files changed, 228 insertions(+), 12 deletions(-) create mode 100644 apps/dokploy/__test__/security/database-password-command-redaction.test.ts 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__/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__/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/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/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..82f320864ce 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; @@ -374,7 +379,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 +402,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(? Date: Wed, 7 Oct 2026 00:53:48 -0400 Subject: [PATCH 2/4] fix: hide compose file previews from callers without env read access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Что: - `compose.randomizeCompose`, `compose.isolatedDeployment` и `compose.getConvertedCompose` возвращают compose-файл только при праве `envVars.read`; иначе вместо файла приходит `__DOKPLOY_REDACTED_SECRET__`. Зачем: - `compose.one` уже скрывает `composeFile` от пользователей без `envVars.read`, но эти три процедуры отдавали тот же файл (часто с inline-секретами) любому с `service.create`. Риски: - Пользователь без `envVars.read` видит в превью randomize/isolated/converted compose плейсхолдер вместо YAML. Ошибок в UI нет, сохранение и деплой не меняются; owner/admin и роли с `envVars.read` видят файл как раньше. Проверки: - Команды и результаты: `CI=true bunx vitest run --config __test__/vitest.config.ts __test__/compose/compose-file-preview-secrets.test.ts` - passed, падает на исходном коде (проверено откатом compose.ts к origin/AgentHits-Dev); папка `__test__/compose` - passed. `bun run typecheck` - exit 0; `bunx biome check ` - 0 ошибок. - Ограничения: UI вручную не открывался. What: - `compose.randomizeCompose`, `compose.isolatedDeployment` and `compose.getConvertedCompose` return the compose file only with `envVars.read`; otherwise the result is `__DOKPLOY_REDACTED_SECRET__`. Why: - `compose.one` already hides `composeFile` from users without `envVars.read`, but these three procedures returned the same file (often with inline secrets) to anyone with `service.create`. Risks: - A user without `envVars.read` sees the placeholder instead of YAML in the randomize/isolated/converted compose previews. No UI errors; saving and deploys are unchanged; owners/admins and roles with `envVars.read` see the file as before. Checks: - Commands and results: `CI=true bunx vitest run --config __test__/vitest.config.ts __test__/compose/compose-file-preview-secrets.test.ts` - passed, fails on the original code (checked by reverting compose.ts to origin/AgentHits-Dev); the `__test__/compose` folder - passed. `bun run typecheck` - exit 0; `bunx biome check ` - 0 errors. - Limitations: the UI was not opened manually. Co-Authored-By: Claude Opus 5.5 --- .../compose-file-preview-secrets.test.ts | 93 +++++++++++++++++++ apps/dokploy/server/api/routers/compose.ts | 13 ++- 2 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 apps/dokploy/__test__/compose/compose-file-preview-secrets.test.ts 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/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 From 7c75cef480fa0ea6109b20bf2d23802f4f96dcee Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:54:03 -0400 Subject: [PATCH 3/4] fix: redact shared env in environment and project mutation responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Что: - `environment.update` и `environment.remove` возвращают `env` только при праве `environmentEnvVars.read`, иначе плейсхолдер. - `project.update` и `project.remove` возвращают `env` только при праве `projectEnvVars.read`. - `project.duplicate` (особенно с `duplicateInSameProject`, где возвращается исходное окружение) скрывает `env` и `project.env` по тем же правам. - Логика "env окружения + env проекта по правам" вынесена в `redactEnvironmentSharedEnvFor` и переиспользуется в чтениях сервисов. Зачем: - Мутации отдавали сырые общие переменные окружения и проекта пользователям, у которых нет права их читать (чтения `environment.one`/`project.one` их уже скрывают). Риски: - UI использует из ответов только id и name, поэтому видимых изменений нет. Внешние API-клиенты без прав чтения env получат плейсхолдер вместо значения в ответах этих мутаций. Проверки: - Команды и результаты: `CI=true bunx vitest run --config __test__/vitest.config.ts __test__/environment/environment-secret-boundary.test.ts __test__/security/project-env-response-redaction.test.ts __test__/placement/placement-ownership-boundary.test.ts` - passed; новые тесты падают на исходном коде (проверено откатом роутеров к origin/AgentHits-Dev). Папки security, environment, placement - passed. `bun run typecheck` - exit 0; `bunx biome check ` - 0 ошибок. - Ограничения: в placement-ownership-boundary только добавлен мок `hasPermission`, который теперь вызывают мутации. What: - `environment.update` and `environment.remove` return `env` only with `environmentEnvVars.read`, otherwise the placeholder. - `project.update` and `project.remove` return `env` only with `projectEnvVars.read`. - `project.duplicate` (notably with `duplicateInSameProject`, which returns the source environment) hides `env` and `project.env` by the same permissions. - The "environment env + project env by permission" logic moved into `redactEnvironmentSharedEnvFor` and is reused by service reads. Why: - These mutations returned raw shared environment and project variables to users without permission to read them (the `environment.one`/`project.one` reads already hide them). Risks: - The UI only uses ids and names from these responses, so nothing visible changes. External API clients without env read permission get the placeholder in these mutation responses. Checks: - Commands and results: `CI=true bunx vitest run --config __test__/vitest.config.ts __test__/environment/environment-secret-boundary.test.ts __test__/security/project-env-response-redaction.test.ts __test__/placement/placement-ownership-boundary.test.ts` - passed; the new tests fail on the original code (checked by reverting the routers to origin/AgentHits-Dev). The security, environment and placement folders - passed. `bun run typecheck` - exit 0; `bunx biome check ` - 0 errors. - Limitations: placement-ownership-boundary only gains a `hasPermission` mock that the mutations now call. Co-Authored-By: Claude Opus 5.5 --- .../environment-secret-boundary.test.ts | 39 ++++- .../placement-ownership-boundary.test.ts | 2 + .../project-env-response-redaction.test.ts | 158 ++++++++++++++++++ .../dokploy/server/api/routers/environment.ts | 8 +- apps/dokploy/server/api/routers/project.ts | 17 +- .../server/src/utils/security/redaction.ts | 50 ++++-- 6 files changed, 252 insertions(+), 22 deletions(-) create mode 100644 apps/dokploy/__test__/security/project-env-response-redaction.test.ts 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/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/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/packages/server/src/utils/security/redaction.ts b/packages/server/src/utils/security/redaction.ts index 82f320864ce..d342870ecab 100644 --- a/packages/server/src/utils/security/redaction.ts +++ b/packages/server/src/utils/security/redaction.ts @@ -89,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 = < From efbc20da6e01a8f1464ea1c7007de2534dd7f107 Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:54:15 -0400 Subject: [PATCH 4/4] fix: return only profile fields in member listings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Что: - `user.all` и `user.one` загружают у связанного пользователя только поля профиля: id, firstName, lastName, email, emailVerified, image, banned, twoFactorEnabled, createdAt. Зачем: - Раньше возвращалась вся строка user: licenseKey, stripeCustomerId, stripeSubscriptionId, trustedOrigins, banReason/banExpires и прочие настройки владельца аккаунта - любому с правом member.read или самому участнику. Риски: - UI (список пользователей, выбор пользователя в сессиях, права участника) читает только оставшиеся поля. Внешние API-клиенты, которые брали из этих ответов служебные поля пользователя, их больше не получат. Проверки: - Команды и результаты: `CI=true bunx vitest run --config __test__/vitest.config.ts __test__/users/user-member-listing-fields.test.ts` - passed, падает на исходном коде (проверено откатом user.ts к origin/AgentHits-Dev); папка `__test__/users` - passed. `bun run typecheck` - exit 0; `bunx biome check ` - 0 ошибок. - Ограничения: выборка колонок в тесте эмулируется моком, реальный drizzle-запрос к базе не выполнялся. What: - `user.all` and `user.one` load only profile fields of the related user: id, firstName, lastName, email, emailVerified, image, banned, twoFactorEnabled, createdAt. Why: - The full user row was returned before: licenseKey, stripeCustomerId, stripeSubscriptionId, trustedOrigins, banReason/banExpires and other account-owner settings, to anyone with member.read or to the member themselves. Risks: - The UI (users list, session user picker, member permissions) only reads the remaining fields. External API clients that took internal user fields from these responses no longer get them. Checks: - Commands and results: `CI=true bunx vitest run --config __test__/vitest.config.ts __test__/users/user-member-listing-fields.test.ts` - passed, fails on the original code (checked by reverting user.ts to origin/AgentHits-Dev); the `__test__/users` folder - passed. `bun run typecheck` - exit 0; `bunx biome check ` - 0 errors. - Limitations: the column selection is emulated by the test mock; no real drizzle query ran against a database. Co-Authored-By: Claude Opus 5.5 --- .../users/user-member-listing-fields.test.ts | 156 ++++++++++++++++++ apps/dokploy/server/api/routers/user.ts | 18 +- 2 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 apps/dokploy/__test__/users/user-member-listing-fields.test.ts 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/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 }, }, });