From 3b40d4e927efa119799e069482215396c62d9566 Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:57:13 -0400 Subject: [PATCH] fix: hide shared env and git url credentials in service responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Что: - redactDeployableServiceSecrets теперь маскирует логин и токен в customGitUrl и скрывает env окружения и проекта (environment.env, environment.project.env). redactDatabaseServiceSecrets тоже скрывает env окружения и проекта. Это касается всех ответов, где есть вложенное окружение. - Новые варианты redactDeployableServiceSecretsFor и redactDatabaseServiceSecretsFor оставляют те общие переменные, которые вызывающему разрешено читать. Их вызывают .one у application, compose и шести баз. Права берутся из environmentEnvVars.read и projectEnvVars.read (getSharedEnvReadAccess), как в роутере окружений. - Локальные копии redactCustomGitUrl в роутерах compose и environment удалены: маскирование теперь в общей функции. - Тесты: модульные для функций скрытия; роутеры application и compose (.one без прав и с правами, ответ stop); шесть баз (.one без прав и с правами). Зачем: - find*ById всех восьми типов сервисов подгружает environment.project целиком. Ответы .one, а у приложений ещё stop, start, delete и move, отдавали env окружения и проекта открытым текстом всем, кто видит сервис, в том числе кастомным ролям без права читать общие переменные. - application.one и другие ответы приложений отдавали customGitUrl с токеном внутри адреса. У compose это закрыл PR #5. Риски: - Без environmentEnvVars.read или projectEnvVars.read редактор переменных сервиса больше не подсказывает имена общих переменных: раньше такой пользователь получал и их значения. Владельцы и админы видят всё как раньше. - Если форма git сохраняется с замаскированным адресом, сохранённый адрес остаётся: saveGitProvider и update уже вызывают preserveSecretPlaceholderFields для customGitUrl. Проверки: - Команды и результаты: CI=true bunx vitest run --config __test__/vitest.config.ts __test__/security/secret-redaction.test.ts __test__/security/service-environment-reveal.test.ts __test__/security/database-environment-reveal.test.ts - на старом коде 13 новых тестов падают, после правки все проходят; __test__/security и __test__/environment: 13 файлов, 105 тестов прошли. Полный прогон: 2089 прошли, 1 пропущен, 8 упали только в *.real.test.ts (нет сокета Docker Desktop). bun run typecheck - 4/4 без ошибок. bun run format-and-lint - 0 ошибок, в изменённых файлах нет предупреждений. - Ограничения: в интерфейсе вручную не проверялось; на dev не выкатывалось. What: - redactDeployableServiceSecrets now masks the login and token in customGitUrl and hides the environment and project env (environment.env, environment.project.env). redactDatabaseServiceSecrets hides the environment and project env too. This covers every response that carries the nested environment. - New variants redactDeployableServiceSecretsFor and redactDatabaseServiceSecretsFor keep the shared env the caller may read. The .one procedures of application, compose and the six databases use them. Access comes from environmentEnvVars.read and projectEnvVars.read (getSharedEnvReadAccess), as in the environment router. - The local redactCustomGitUrl copies in the compose and environment routers are removed: the shared function masks the URL now. - Tests: unit tests for the redaction helpers; application and compose routers (.one with and without access, the stop response); six databases (.one with and without access). Why: - find*ById for all eight service types loads environment.project in full. The .one responses, plus stop, start, delete and move for applications, returned the environment and project env in plain text to anyone who can see the service, including custom roles without permission to read shared env. - application.one and other application responses returned customGitUrl with the token inside the URL. PR #5 already fixed this for compose. Risks: - Without environmentEnvVars.read or projectEnvVars.read, the service env editor no longer suggests shared variable names: such users used to get their values as well. Owners and admins see everything as before. - Saving the git form with a masked URL keeps the stored URL: saveGitProvider and update already call preserveSecretPlaceholderFields for customGitUrl. Checks: - Commands and results: CI=true bunx vitest run --config __test__/vitest.config.ts __test__/security/secret-redaction.test.ts __test__/security/service-environment-reveal.test.ts __test__/security/database-environment-reveal.test.ts - on the old code 13 new tests fail, after the fix all pass; __test__/security and __test__/environment: 13 files, 105 tests passed. Full run: 2089 passed, 1 skipped, 8 failed only in *.real.test.ts (no Docker Desktop socket). bun run typecheck - 4/4 clean. bun run format-and-lint - 0 errors, no warnings in the changed files. - Limitations: not checked by hand in the UI; not deployed to dev. Co-Authored-By: Claude Opus 5.5 --- .../database-environment-reveal.test.ts | 40 +++++++++ .../security/secret-redaction.test.ts | 73 ++++++++++++++++ .../service-environment-reveal.test.ts | 83 +++++++++++++++++++ .../dokploy/server/api/routers/application.ts | 9 +- apps/dokploy/server/api/routers/compose.ts | 55 ++++++------ .../dokploy/server/api/routers/environment.ts | 37 ++------- apps/dokploy/server/api/routers/libsql.ts | 11 ++- apps/dokploy/server/api/routers/mariadb.ts | 11 ++- apps/dokploy/server/api/routers/mongo.ts | 11 ++- apps/dokploy/server/api/routers/mysql.ts | 11 ++- apps/dokploy/server/api/routers/postgres.ts | 11 ++- apps/dokploy/server/api/routers/redis.ts | 11 ++- .../server/api/utils/service-environment.ts | 13 ++- .../server/src/utils/security/redaction.ts | 62 +++++++++++++- 14 files changed, 361 insertions(+), 77 deletions(-) diff --git a/apps/dokploy/__test__/security/database-environment-reveal.test.ts b/apps/dokploy/__test__/security/database-environment-reveal.test.ts index 08390417c23..fa298fe746f 100644 --- a/apps/dokploy/__test__/security/database-environment-reveal.test.ts +++ b/apps/dokploy/__test__/security/database-environment-reveal.test.ts @@ -24,6 +24,7 @@ const mocks = vi.hoisted(() => ({ findMySqlById: vi.fn(), findPostgresById: vi.fn(), findRedisById: vi.fn(), + hasPermission: vi.fn(), getServiceContainerCommand: vi.fn( (appName: string) => `docker ps -q --filter "label=com.docker.swarm.service.name=${appName}" | head -n 1`, @@ -96,6 +97,7 @@ vi.mock("@dokploy/server/services/permission", () => ({ checkServiceAccess: mocks.checkServiceAccess, checkServicePermissionAndAccess: mocks.checkServicePermissionAndAccess, findMemberByUserId: mocks.noop, + hasPermission: mocks.hasPermission, })); vi.mock("@/server/api/utils/audit", () => ({ @@ -141,7 +143,9 @@ const databaseService = ( databasePassword: `${id}-password`, databaseRootPassword: `${id}-root-password`, environment: { + env: "ENVIRONMENT_SHARED=secret", project: { + env: "PROJECT_SHARED=secret", organizationId, }, }, @@ -191,6 +195,7 @@ describe("database environment reveal boundary", () => { vi.clearAllMocks(); mocks.checkServiceAccess.mockResolvedValue(undefined); mocks.checkServicePermissionAndAccess.mockResolvedValue(undefined); + mocks.hasPermission.mockResolvedValue(false); for (const testCase of cases) { testCase.find.mockResolvedValue( databaseService(testCase.idField, testCase.id), @@ -221,4 +226,39 @@ describe("database environment reveal boundary", () => { ); }, ); + + it.each(cases)( + "hides shared env in $id reads without shared env read permissions", + async (testCase) => { + const normalRead = await testCase.router + .createCaller(createContext()) + .one({ [testCase.idField]: testCase.id } as never); + + expect(normalRead.environment).toEqual({ + env: REDACTED_SECRET_VALUE, + project: { env: REDACTED_SECRET_VALUE, organizationId: "org-1" }, + }); + }, + ); + + it.each(cases)( + "keeps shared env in $id reads for callers that can read it", + async (testCase) => { + mocks.hasPermission.mockImplementation( + async (_ctx: unknown, permissions: Record) => + "environmentEnvVars" in permissions || + "projectEnvVars" in permissions, + ); + + const normalRead = await testCase.router + .createCaller(createContext()) + .one({ [testCase.idField]: testCase.id } as never); + + expect(normalRead.env).toBe(REDACTED_SECRET_VALUE); + expect(normalRead.environment).toEqual({ + env: "ENVIRONMENT_SHARED=secret", + project: { env: "PROJECT_SHARED=secret", organizationId: "org-1" }, + }); + }, + ); }); diff --git a/apps/dokploy/__test__/security/secret-redaction.test.ts b/apps/dokploy/__test__/security/secret-redaction.test.ts index 1cd816292d2..d8b17c204d1 100644 --- a/apps/dokploy/__test__/security/secret-redaction.test.ts +++ b/apps/dokploy/__test__/security/secret-redaction.test.ts @@ -5,7 +5,9 @@ import { REDACTED_SECRET_VALUE, redactBackupScheduleSecrets, redactDatabaseServiceSecrets, + redactDatabaseServiceSecretsFor, redactDeployableServiceSecrets, + redactDeployableServiceSecretsFor, redactProjectNestedSecrets, redactRollbackFullContextSecrets, redactSecretFields, @@ -157,6 +159,77 @@ describe("shared secret redaction helpers", () => { }); }); + it("redacts git url credentials and shared env from deployable service reads", () => { + const redacted = redactDeployableServiceSecrets({ + customGitUrl: "https://user:token@example.com/org/repo.git", + environment: { + env: "ENVIRONMENT_SHARED=secret", + project: { env: "PROJECT_SHARED=secret", organizationId: "org-1" }, + }, + }); + + expect(redacted).toEqual({ + customGitUrl: `https://${REDACTED_SECRET_VALUE}@example.com/org/repo.git`, + environment: { + env: REDACTED_SECRET_VALUE, + project: { env: REDACTED_SECRET_VALUE, organizationId: "org-1" }, + }, + }); + }); + + it("keeps only the shared env a reader may read", () => { + const service = { + env: "TOKEN=secret", + environment: { + env: "ENVIRONMENT_SHARED=secret", + project: { env: "PROJECT_SHARED=secret" }, + }, + }; + + expect( + redactDeployableServiceSecretsFor(service, { + environmentEnv: true, + projectEnv: false, + }), + ).toEqual({ + env: REDACTED_SECRET_VALUE, + environment: { + env: "ENVIRONMENT_SHARED=secret", + project: { env: REDACTED_SECRET_VALUE }, + }, + }); + expect( + redactDatabaseServiceSecretsFor(service, { + environmentEnv: false, + projectEnv: true, + }), + ).toEqual({ + env: REDACTED_SECRET_VALUE, + environment: { + env: REDACTED_SECRET_VALUE, + project: { env: "PROJECT_SHARED=secret" }, + }, + }); + }); + + it("redacts shared env from database service reads", () => { + expect( + redactDatabaseServiceSecrets({ + databasePassword: "secret", + environment: { + env: "ENVIRONMENT_SHARED=secret", + project: { env: "PROJECT_SHARED=secret" }, + }, + }), + ).toEqual({ + databasePassword: REDACTED_SECRET_VALUE, + environment: { + env: REDACTED_SECRET_VALUE, + project: { env: REDACTED_SECRET_VALUE }, + }, + }); + }); + it("redacts database service read credentials", () => { const redacted = redactDatabaseServiceSecrets({ env: "PGSSLMODE=require", diff --git a/apps/dokploy/__test__/security/service-environment-reveal.test.ts b/apps/dokploy/__test__/security/service-environment-reveal.test.ts index edfaf567de0..88f2ae7cce3 100644 --- a/apps/dokploy/__test__/security/service-environment-reveal.test.ts +++ b/apps/dokploy/__test__/security/service-environment-reveal.test.ts @@ -225,8 +225,11 @@ const application = (organizationId = "org-1") => ({ env: "APP_SECRET=secret", buildArgs: "BUILD_ARG_SECRET=secret", buildSecrets: "BUILD_SECRET=secret", + customGitUrl: "https://app-token@example.com/org/private.git", environment: { + env: "ENVIRONMENT_SHARED=secret", project: { + env: "PROJECT_SHARED=secret", organizationId, }, }, @@ -242,7 +245,9 @@ const compose = (organizationId = "org-1") => ({ "services:\n api:\n environment:\n TOKEN: compose-secret", customGitUrl: "https://compose-token@example.com/org/private.git", environment: { + env: "ENVIRONMENT_SHARED=secret", project: { + env: "PROJECT_SHARED=secret", organizationId, }, }, @@ -250,6 +255,19 @@ const compose = (organizationId = "org-1") => ({ const redactedCustomGitUrl = `https://${REDACTED_SECRET_VALUE}@example.com/org/private.git`; +const redactedSharedEnvironment = { + env: REDACTED_SECRET_VALUE, + project: { + env: REDACTED_SECRET_VALUE, + organizationId: "org-1", + }, +}; + +const canReadSharedEnv = async ( + _ctx: unknown, + permissions: Record, +) => "environmentEnvVars" in permissions || "projectEnvVars" in permissions; + describe("service environment reveal boundary", () => { beforeEach(() => { vi.clearAllMocks(); @@ -347,6 +365,71 @@ describe("service environment reveal boundary", () => { }); }); + it("hides shared env and git url credentials in application reads", async () => { + const normalRead = await applicationRouter + .createCaller(createContext()) + .one({ applicationId: "app-1" }); + + expect(normalRead).toMatchObject({ + customGitUrl: redactedCustomGitUrl, + environment: redactedSharedEnvironment, + }); + expect(permissionMocks.hasPermission).toHaveBeenCalledWith( + expect.anything(), + { environmentEnvVars: ["read"] }, + ); + expect(permissionMocks.hasPermission).toHaveBeenCalledWith( + expect.anything(), + { projectEnvVars: ["read"] }, + ); + }); + + it("keeps shared env in application and compose reads for callers that can read it", async () => { + permissionMocks.hasPermission.mockImplementation(canReadSharedEnv); + + const applicationRead = await applicationRouter + .createCaller(createContext()) + .one({ applicationId: "app-1" }); + const composeRead = await composeRouter + .createCaller(createContext()) + .one({ composeId: "compose-1" }); + + for (const read of [applicationRead, composeRead]) { + expect(read).toMatchObject({ + env: REDACTED_SECRET_VALUE, + customGitUrl: redactedCustomGitUrl, + environment: { + env: "ENVIRONMENT_SHARED=secret", + project: { env: "PROJECT_SHARED=secret" }, + }, + }); + } + }); + + it("hides shared env in compose reads", async () => { + const normalRead = await composeRouter + .createCaller(createContext()) + .one({ composeId: "compose-1" }); + + expect(normalRead).toMatchObject({ + environment: redactedSharedEnvironment, + }); + }); + + it("hides shared env and git url credentials in application action responses", async () => { + permissionMocks.hasPermission.mockImplementation(canReadSharedEnv); + + const stopped = await applicationRouter + .createCaller(createContext()) + .stop({ applicationId: "app-1" }); + + expect(stopped).toMatchObject({ + env: REDACTED_SECRET_VALUE, + customGitUrl: redactedCustomGitUrl, + environment: redactedSharedEnvironment, + }); + }); + it("denies reveal when the service belongs to another organization", async () => { serverMocks.findApplicationById.mockResolvedValueOnce(application("org-2")); diff --git a/apps/dokploy/server/api/routers/application.ts b/apps/dokploy/server/api/routers/application.ts index 4d10c36bc0e..4db0c8e8f0a 100644 --- a/apps/dokploy/server/api/routers/application.ts +++ b/apps/dokploy/server/api/routers/application.ts @@ -51,6 +51,7 @@ import { assertCustomGitUrlAllowed } from "@dokploy/server/utils/providers/git"; import { preserveSecretPlaceholderFields, redactDeployableServiceSecrets, + redactDeployableServiceSecretsFor, } from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; @@ -66,7 +67,10 @@ import { audit } from "@/server/api/utils/audit"; import { assertDeploySourceCredentialAccess } from "@/server/api/utils/deploy-source-access"; import { assertContainerMetricsServiceAccess } from "@/server/api/utils/monitoring-access"; import { assertTargetEnvironmentAccess } from "@/server/api/utils/placement-access"; -import { assertServiceEnvironmentReadAccess } from "@/server/api/utils/service-environment"; +import { + assertServiceEnvironmentReadAccess, + getSharedEnvReadAccess, +} from "@/server/api/utils/service-environment"; import { apiCreateApplication, apiDeployApplication, @@ -445,8 +449,9 @@ export const applicationRouter = createTRPCRouter({ } return { - ...redactDeployableServiceSecrets( + ...redactDeployableServiceSecretsFor( redactGitProviderSecrets(application), + await getSharedEnvReadAccess(ctx), ), hasGitProviderAccess, unauthorizedProvider, diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts index bf4f82983c3..1669b4f3652 100644 --- a/apps/dokploy/server/api/routers/compose.ts +++ b/apps/dokploy/server/api/routers/compose.ts @@ -60,9 +60,9 @@ import { processTemplate } from "@dokploy/server/templates/processors"; import { assertCustomGitUrlAllowed } from "@dokploy/server/utils/providers/git"; import { preserveSecretPlaceholderFields, - redactDeployableServiceSecrets, + redactDeployableServiceSecretsFor, redactSecretFields, - redactSensitiveText, + type SharedEnvReadAccess, } from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; @@ -102,46 +102,37 @@ import { createTRPCRouter, protectedProcedure } from "../trpc"; import { audit } from "../utils/audit"; import { assertDeploySourceCredentialAccess } from "../utils/deploy-source-access"; import { assertTargetEnvironmentAccess } from "../utils/placement-access"; -import { assertServiceEnvironmentReadAccess } from "../utils/service-environment"; +import { + assertServiceEnvironmentReadAccess, + getSharedEnvReadAccess, +} from "../utils/service-environment"; type SecretRecord = Record; -const redactCustomGitUrl = ( - record: T, -) => { - if (!record) { - return record; - } - - const redacted = { ...record }; - if ("customGitUrl" in redacted) { - redacted.customGitUrl = redactSensitiveText( - redacted.customGitUrl as string | null | undefined, - ); - } - - return redacted as T; -}; - const redactComposeSecrets = ( record: T, - { redactComposeFile = true }: { redactComposeFile?: boolean } = {}, + { + redactComposeFile = true, + sharedEnvAccess, + }: { + redactComposeFile?: boolean; + sharedEnvAccess?: SharedEnvReadAccess; + } = {}, ) => { if (!record) { return record; } - const redacted = redactCustomGitUrl( - redactDeployableServiceSecrets( - redactGitProviderSecrets( - record as T & { - bitbucket?: object | null; - gitea?: object | null; - github?: object | null; - gitlab?: object | null; - }, - ), + const redacted = redactDeployableServiceSecretsFor( + redactGitProviderSecrets( + record as T & { + bitbucket?: object | null; + gitea?: object | null; + github?: object | null; + gitlab?: object | null; + }, ), + sharedEnvAccess, ); return redactComposeFile @@ -360,11 +351,13 @@ export const composeRouter = createTRPCRouter({ } const canReadEnvVars = await hasPermission(ctx, { envVars: ["read"] }); + const sharedEnvAccess = await getSharedEnvReadAccess(ctx); return { // The compose file editor loads composeFile from this response. ...redactComposeSecrets(compose, { redactComposeFile: !canReadEnvVars, + sharedEnvAccess, }), hasGitProviderAccess, unauthorizedProvider, diff --git a/apps/dokploy/server/api/routers/environment.ts b/apps/dokploy/server/api/routers/environment.ts index 004121b2df7..1345ed0e2dd 100644 --- a/apps/dokploy/server/api/routers/environment.ts +++ b/apps/dokploy/server/api/routers/environment.ts @@ -23,7 +23,6 @@ import { redactDatabaseServiceSecrets, redactDeployableServiceSecrets, redactSecretFields, - redactSensitiveText, } from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; @@ -44,24 +43,6 @@ import { type SecretRecord = Record; -const redactCustomGitUrl = ( - record: T, -) => { - if (!record) { - return record; - } - - const redacted = { ...record }; - - if ("customGitUrl" in redacted) { - redacted.customGitUrl = redactSensitiveText( - redacted.customGitUrl as string | null | undefined, - ); - } - - return redacted as T; -}; - const redactDeployableEnvironmentServiceSecrets = < T extends SecretRecord | null | undefined, >( @@ -71,16 +52,14 @@ const redactDeployableEnvironmentServiceSecrets = < return record; } - return redactCustomGitUrl( - redactDeployableServiceSecrets( - redactGitProviderSecrets( - record as T & { - bitbucket?: object | null; - gitea?: object | null; - github?: object | null; - gitlab?: object | null; - }, - ), + return redactDeployableServiceSecrets( + redactGitProviderSecrets( + record as T & { + bitbucket?: object | null; + gitea?: object | null; + github?: object | null; + gitlab?: object | null; + }, ), ); }; diff --git a/apps/dokploy/server/api/routers/libsql.ts b/apps/dokploy/server/api/routers/libsql.ts index 50e88ab3f59..1b41b017bd3 100644 --- a/apps/dokploy/server/api/routers/libsql.ts +++ b/apps/dokploy/server/api/routers/libsql.ts @@ -27,6 +27,7 @@ import { import { preserveSecretPlaceholderFields, redactDatabaseServiceSecrets, + redactDatabaseServiceSecretsFor, } from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; @@ -34,7 +35,10 @@ import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { audit } from "@/server/api/utils/audit"; import { assertTargetEnvironmentAccess } from "@/server/api/utils/placement-access"; -import { assertServiceEnvironmentReadAccess } from "@/server/api/utils/service-environment"; +import { + assertServiceEnvironmentReadAccess, + getSharedEnvReadAccess, +} from "@/server/api/utils/service-environment"; import { db } from "@/server/db"; import { apiChangeLibsqlStatus, @@ -125,7 +129,10 @@ export const libsqlRouter = createTRPCRouter({ message: "You are not authorized to access this Libsql", }); } - return redactDatabaseServiceSecrets(libsql); + return redactDatabaseServiceSecretsFor( + libsql, + await getSharedEnvReadAccess(ctx), + ); }), revealEnvironment: protectedProcedure diff --git a/apps/dokploy/server/api/routers/mariadb.ts b/apps/dokploy/server/api/routers/mariadb.ts index 5108a35ae54..ada5a8008d5 100644 --- a/apps/dokploy/server/api/routers/mariadb.ts +++ b/apps/dokploy/server/api/routers/mariadb.ts @@ -32,6 +32,7 @@ import { getServiceContainerCommand } from "@dokploy/server/utils/backups/utils" import { preserveSecretPlaceholderFields, redactDatabaseServiceSecrets, + redactDatabaseServiceSecretsFor, } from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { observable } from "@trpc/server/observable"; @@ -41,7 +42,10 @@ import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { audit } from "@/server/api/utils/audit"; import { buildMysqlPasswordChangeCommand } from "@/server/api/utils/database-password"; import { assertTargetEnvironmentAccess } from "@/server/api/utils/placement-access"; -import { assertServiceEnvironmentReadAccess } from "@/server/api/utils/service-environment"; +import { + assertServiceEnvironmentReadAccess, + getSharedEnvReadAccess, +} from "@/server/api/utils/service-environment"; import { apiChangeMariaDBStatus, apiCreateMariaDB, @@ -141,7 +145,10 @@ export const mariadbRouter = createTRPCRouter({ message: "You are not authorized to access this Mariadb", }); } - return redactDatabaseServiceSecrets(mariadb); + return redactDatabaseServiceSecretsFor( + mariadb, + await getSharedEnvReadAccess(ctx), + ); }), revealEnvironment: protectedProcedure diff --git a/apps/dokploy/server/api/routers/mongo.ts b/apps/dokploy/server/api/routers/mongo.ts index 0010c9cde2d..87f821127ef 100644 --- a/apps/dokploy/server/api/routers/mongo.ts +++ b/apps/dokploy/server/api/routers/mongo.ts @@ -31,6 +31,7 @@ import { getServiceContainerCommand } from "@dokploy/server/utils/backups/utils" import { preserveSecretPlaceholderFields, redactDatabaseServiceSecrets, + redactDatabaseServiceSecretsFor, } from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; @@ -39,7 +40,10 @@ import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { audit } from "@/server/api/utils/audit"; import { buildMongoPasswordChangeCommand } from "@/server/api/utils/database-password"; import { assertTargetEnvironmentAccess } from "@/server/api/utils/placement-access"; -import { assertServiceEnvironmentReadAccess } from "@/server/api/utils/service-environment"; +import { + assertServiceEnvironmentReadAccess, + getSharedEnvReadAccess, +} from "@/server/api/utils/service-environment"; import { apiChangeMongoStatus, apiCreateMongo, @@ -144,7 +148,10 @@ export const mongoRouter = createTRPCRouter({ message: "You are not authorized to access this mongo", }); } - return redactDatabaseServiceSecrets(mongo); + return redactDatabaseServiceSecretsFor( + mongo, + await getSharedEnvReadAccess(ctx), + ); }), revealEnvironment: protectedProcedure diff --git a/apps/dokploy/server/api/routers/mysql.ts b/apps/dokploy/server/api/routers/mysql.ts index 2f7f2854462..df2c93d6d7d 100644 --- a/apps/dokploy/server/api/routers/mysql.ts +++ b/apps/dokploy/server/api/routers/mysql.ts @@ -32,6 +32,7 @@ import { getServiceContainerCommand } from "@dokploy/server/utils/backups/utils" import { preserveSecretPlaceholderFields, redactDatabaseServiceSecrets, + redactDatabaseServiceSecretsFor, } from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; @@ -40,7 +41,10 @@ import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { audit } from "@/server/api/utils/audit"; import { buildMysqlPasswordChangeCommand } from "@/server/api/utils/database-password"; import { assertTargetEnvironmentAccess } from "@/server/api/utils/placement-access"; -import { assertServiceEnvironmentReadAccess } from "@/server/api/utils/service-environment"; +import { + assertServiceEnvironmentReadAccess, + getSharedEnvReadAccess, +} from "@/server/api/utils/service-environment"; import { apiChangeMySqlStatus, apiCreateMySql, @@ -145,7 +149,10 @@ export const mysqlRouter = createTRPCRouter({ message: "You are not authorized to access this MySQL", }); } - return redactDatabaseServiceSecrets(mysql); + return redactDatabaseServiceSecretsFor( + mysql, + await getSharedEnvReadAccess(ctx), + ); }), revealEnvironment: protectedProcedure diff --git a/apps/dokploy/server/api/routers/postgres.ts b/apps/dokploy/server/api/routers/postgres.ts index 1bab1548ec0..31bc6549245 100644 --- a/apps/dokploy/server/api/routers/postgres.ts +++ b/apps/dokploy/server/api/routers/postgres.ts @@ -32,6 +32,7 @@ import { getServiceContainerCommand } from "@dokploy/server/utils/backups/utils" import { preserveSecretPlaceholderFields, redactDatabaseServiceSecrets, + redactDatabaseServiceSecretsFor, } from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; @@ -40,7 +41,10 @@ import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { audit } from "@/server/api/utils/audit"; import { buildPostgresPasswordChangeCommand } from "@/server/api/utils/database-password"; import { assertTargetEnvironmentAccess } from "@/server/api/utils/placement-access"; -import { assertServiceEnvironmentReadAccess } from "@/server/api/utils/service-environment"; +import { + assertServiceEnvironmentReadAccess, + getSharedEnvReadAccess, +} from "@/server/api/utils/service-environment"; import { apiChangePostgresStatus, apiCreatePostgres, @@ -148,7 +152,10 @@ export const postgresRouter = createTRPCRouter({ message: "You are not authorized to access this Postgres", }); } - return redactDatabaseServiceSecrets(postgres); + return redactDatabaseServiceSecretsFor( + postgres, + await getSharedEnvReadAccess(ctx), + ); }), revealEnvironment: protectedProcedure diff --git a/apps/dokploy/server/api/routers/redis.ts b/apps/dokploy/server/api/routers/redis.ts index c02cfeda98f..99059bbf5cf 100644 --- a/apps/dokploy/server/api/routers/redis.ts +++ b/apps/dokploy/server/api/routers/redis.ts @@ -30,6 +30,7 @@ import { getServiceContainerCommand } from "@dokploy/server/utils/backups/utils" import { preserveSecretPlaceholderFields, redactDatabaseServiceSecrets, + redactDatabaseServiceSecretsFor, } from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; @@ -38,7 +39,10 @@ import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { audit } from "@/server/api/utils/audit"; import { buildRedisPasswordChangeCommand } from "@/server/api/utils/database-password"; import { assertTargetEnvironmentAccess } from "@/server/api/utils/placement-access"; -import { assertServiceEnvironmentReadAccess } from "@/server/api/utils/service-environment"; +import { + assertServiceEnvironmentReadAccess, + getSharedEnvReadAccess, +} from "@/server/api/utils/service-environment"; import { apiChangeRedisStatus, apiCreateRedis, @@ -135,7 +139,10 @@ export const redisRouter = createTRPCRouter({ message: "You are not authorized to access this Redis", }); } - return redactDatabaseServiceSecrets(redis); + return redactDatabaseServiceSecretsFor( + redis, + await getSharedEnvReadAccess(ctx), + ); }), revealEnvironment: protectedProcedure diff --git a/apps/dokploy/server/api/utils/service-environment.ts b/apps/dokploy/server/api/utils/service-environment.ts index 76b01bec359..2d4edc36c2d 100644 --- a/apps/dokploy/server/api/utils/service-environment.ts +++ b/apps/dokploy/server/api/utils/service-environment.ts @@ -1,4 +1,8 @@ -import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission"; +import { + checkServicePermissionAndAccess, + hasPermission, +} from "@dokploy/server/services/permission"; +import type { SharedEnvReadAccess } from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; type ServiceEnvironmentContext = Parameters< @@ -43,3 +47,10 @@ export const assertServiceEnvironmentReadAccess = async < return service; }; + +export const getSharedEnvReadAccess = async ( + ctx: Parameters[0], +): Promise => ({ + environmentEnv: await hasPermission(ctx, { environmentEnvVars: ["read"] }), + projectEnv: await hasPermission(ctx, { projectEnvVars: ["read"] }), +}); diff --git a/packages/server/src/utils/security/redaction.ts b/packages/server/src/utils/security/redaction.ts index a55be3f9ef4..11b16e277ce 100644 --- a/packages/server/src/utils/security/redaction.ts +++ b/packages/server/src/utils/security/redaction.ts @@ -74,10 +74,56 @@ const redactNestedServerSecrets = (server: T): T => { return redacted as T; }; +export type SharedEnvReadAccess = { + environmentEnv: boolean; + projectEnv: boolean; +}; + +const NO_SHARED_ENV_READ_ACCESS: SharedEnvReadAccess = { + environmentEnv: false, + 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, + access: SharedEnvReadAccess, +) => { + if (!record?.environment || typeof record.environment !== "object") { + return record; + } + + let environment = record.environment as SecretRecord; + if (!access.environmentEnv) { + environment = redactSecretFields(environment, ["env"]); + } + if ( + !access.projectEnv && + environment.project && + typeof environment.project === "object" + ) { + environment = { + ...environment, + project: redactSecretFields(environment.project as SecretRecord, ["env"]), + }; + } + + return { ...record, environment } as T; +}; + export const redactDeployableServiceSecrets = < T extends SecretRecord | null | undefined, >( record: T, +) => redactDeployableServiceSecretsFor(record); + +export const redactDeployableServiceSecretsFor = < + T extends SecretRecord | null | undefined, +>( + record: T, + sharedEnvAccess: SharedEnvReadAccess = NO_SHARED_ENV_READ_ACCESS, ) => { const redacted = redactSecretFields(record, [ "env", @@ -94,6 +140,11 @@ export const redactDeployableServiceSecrets = < } const withRelations = { ...redacted }; + if ("customGitUrl" in withRelations) { + withRelations.customGitUrl = redactSensitiveText( + withRelations.customGitUrl as string | null | undefined, + ); + } if (withRelations.security && typeof withRelations.security === "object") { withRelations.security = Array.isArray(withRelations.security) ? withRelations.security.map(redactSecuritySecrets) @@ -104,13 +155,20 @@ export const redactDeployableServiceSecrets = < withRelations[key] = redactNestedServerSecrets(withRelations[key]); } } - return withRelations as T; + return redactServiceSharedEnv(withRelations as T, sharedEnvAccess); }; export const redactDatabaseServiceSecrets = < T extends SecretRecord | null | undefined, >( record: T, +) => redactDatabaseServiceSecretsFor(record); + +export const redactDatabaseServiceSecretsFor = < + T extends SecretRecord | null | undefined, +>( + record: T, + sharedEnvAccess: SharedEnvReadAccess = NO_SHARED_ENV_READ_ACCESS, ) => { const redacted = redactSecretFields(record, [ "env", @@ -125,7 +183,7 @@ export const redactDatabaseServiceSecrets = < if ("server" in withRelations) { withRelations.server = redactNestedServerSecrets(withRelations.server); } - return withRelations as T; + return redactServiceSharedEnv(withRelations as T, sharedEnvAccess); }; export const redactAiSettingsSecrets = <