From 9134c9d52b0fad427cc760c81249278a73f2d387 Mon Sep 17 00:00:00 2001 From: agentHits <140916359+agentHits@users.noreply.github.com> Date: Mon, 8 Jun 2026 23:22:31 +0300 Subject: [PATCH 1/5] feat: add safe application env upsert --- .../env/application-env-upsert.test.ts | 124 +++++++++++++++++ apps/dokploy/__test__/env/upsert.test.ts | 86 ++++++++++++ .../dokploy/server/api/routers/application.ts | 76 ++++++++++ packages/server/src/db/schema/application.ts | 37 +++++ packages/server/src/index.ts | 1 + packages/server/src/services/application.ts | 42 ++++++ packages/server/src/utils/env-upsert.ts | 130 ++++++++++++++++++ 7 files changed, 496 insertions(+) create mode 100644 apps/dokploy/__test__/env/application-env-upsert.test.ts create mode 100644 apps/dokploy/__test__/env/upsert.test.ts create mode 100644 packages/server/src/utils/env-upsert.ts diff --git a/apps/dokploy/__test__/env/application-env-upsert.test.ts b/apps/dokploy/__test__/env/application-env-upsert.test.ts new file mode 100644 index 0000000000..c0c564b1fd --- /dev/null +++ b/apps/dokploy/__test__/env/application-env-upsert.test.ts @@ -0,0 +1,124 @@ +import { upsertApplicationEnvironment } from "@dokploy/server/services/application"; +import { getApplicationEnvRevision } from "@dokploy/server/utils/env-upsert"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const dbMocks = vi.hoisted(() => { + const returning = vi.fn(); + const where = vi.fn(() => ({ returning })); + const set = vi.fn(() => ({ where })); + const update = vi.fn(() => ({ set })); + const findFirst = vi.fn(); + + return { + findFirst, + returning, + set, + update, + where, + }; +}); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + applications: { + findFirst: dbMocks.findFirst, + }, + }, + update: dbMocks.update, + }, +})); + +const mockApplication = (env: string | null = null) => ({ + applicationId: "app_1", + env, +}); + +describe("upsertApplicationEnvironment", () => { + beforeEach(() => { + vi.clearAllMocks(); + dbMocks.returning.mockResolvedValue([mockApplication()]); + }); + + it("returns dry run metadata without saving raw values", async () => { + dbMocks.findFirst.mockResolvedValue( + mockApplication("API_URL=https://old.example.com\nREDIS_PASSWORD=old"), + ); + + const result = await upsertApplicationEnvironment({ + applicationId: "app_1", + variables: { + API_URL: "https://api.example.com", + REDIS_PASSWORD: "new", + }, + dryRun: true, + }); + + expect(dbMocks.update).not.toHaveBeenCalled(); + expect(result).toEqual({ + applicationId: "app_1", + changed: true, + revision: getApplicationEnvRevision( + "app_1", + "API_URL=https://old.example.com\nREDIS_PASSWORD=old", + ), + dryRun: true, + variables: [ + { + name: "API_URL", + action: "updated", + secret: false, + }, + { + name: "REDIS_PASSWORD", + action: "updated", + secret: true, + }, + ], + }); + expect(JSON.stringify(result)).not.toContain("new"); + }); + + it("saves only the merged environment when the revision matches", async () => { + const currentEnv = "API_URL=https://old.example.com\nREDIS_PASSWORD=old"; + dbMocks.findFirst.mockResolvedValue(mockApplication(currentEnv)); + + const result = await upsertApplicationEnvironment({ + applicationId: "app_1", + variables: { + API_URL: "https://api.example.com", + REDIS_HOST: "redis-dev", + }, + expectedRevision: getApplicationEnvRevision("app_1", currentEnv), + }); + + expect(dbMocks.set).toHaveBeenCalledWith({ + env: "API_URL=https://api.example.com\nREDIS_PASSWORD=old\nREDIS_HOST=redis-dev", + }); + expect(result.changed).toBe(true); + expect(result.revision).toBe( + getApplicationEnvRevision( + "app_1", + "API_URL=https://api.example.com\nREDIS_PASSWORD=old\nREDIS_HOST=redis-dev", + ), + ); + }); + + it("rejects stale expected revisions without writing", async () => { + dbMocks.findFirst.mockResolvedValue(mockApplication("API_URL=https://old")); + + await expect( + upsertApplicationEnvironment({ + applicationId: "app_1", + variables: { + API_URL: "https://api.example.com", + }, + expectedRevision: "env:stale", + }), + ).rejects.toMatchObject({ + code: "CONFLICT", + message: "Application environment revision does not match", + }); + expect(dbMocks.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/dokploy/__test__/env/upsert.test.ts b/apps/dokploy/__test__/env/upsert.test.ts new file mode 100644 index 0000000000..45b0664fde --- /dev/null +++ b/apps/dokploy/__test__/env/upsert.test.ts @@ -0,0 +1,86 @@ +import { + getApplicationEnvRevision, + isSecretEnvName, + upsertEnvVariables, +} from "@dokploy/server/utils/env-upsert"; +import { describe, expect, it } from "vitest"; + +describe("upsertEnvVariables", () => { + it("updates existing variables and appends new variables without removing existing secrets", () => { + const result = upsertEnvVariables( + `# Existing app env +API_URL=https://old.example.com +REDIS_PASSWORD=secret-value +`, + { + API_URL: "https://api.example.com", + REDIS_HOST: "redis-dev", + REDIS_PASSWORD: "secret-value", + }, + ); + + expect(result.changed).toBe(true); + expect(result.env).toBe(`# Existing app env +API_URL=https://api.example.com +REDIS_PASSWORD=secret-value +REDIS_HOST=redis-dev +`); + expect(result.variables).toEqual([ + { + name: "API_URL", + action: "updated", + secret: false, + }, + { + name: "REDIS_HOST", + action: "created", + secret: false, + }, + { + name: "REDIS_PASSWORD", + action: "unchanged", + secret: true, + }, + ]); + }); + + it("quotes values that would be unsafe as plain dotenv values", () => { + const result = upsertEnvVariables("", { + PLAIN_VALUE: "redis-dev", + SPACED_VALUE: "redis dev", + MULTILINE_VALUE: "first\nsecond", + HASH_VALUE: "value # not a comment", + }); + + expect(result.env).toBe(`PLAIN_VALUE=redis-dev +SPACED_VALUE="redis dev" +MULTILINE_VALUE="first\\nsecond" +HASH_VALUE="value # not a comment"`); + }); + + it("marks common credential names as secret metadata", () => { + expect(isSecretEnvName("REDIS_PASSWORD")).toBe(true); + expect(isSecretEnvName("API_TOKEN")).toBe(true); + expect(isSecretEnvName("PUBLIC_URL")).toBe(false); + }); + + it("creates a stable opaque revision that changes when the env changes", () => { + const firstRevision = getApplicationEnvRevision( + "app_1", + "REDIS_PASSWORD=secret-value", + ); + const sameRevision = getApplicationEnvRevision( + "app_1", + "REDIS_PASSWORD=secret-value", + ); + const nextRevision = getApplicationEnvRevision( + "app_1", + "REDIS_PASSWORD=new-secret-value", + ); + + expect(firstRevision).toBe(sameRevision); + expect(firstRevision).not.toBe(nextRevision); + expect(firstRevision).toMatch(/^env:/); + expect(firstRevision).not.toContain("secret-value"); + }); +}); diff --git a/apps/dokploy/server/api/routers/application.ts b/apps/dokploy/server/api/routers/application.ts index de847a3014..53d008696e 100644 --- a/apps/dokploy/server/api/routers/application.ts +++ b/apps/dokploy/server/api/routers/application.ts @@ -26,6 +26,7 @@ import { updateApplication, updateApplicationStatus, updateDeploymentStatus, + upsertApplicationEnvironment, writeConfig, writeConfigRemote, } from "@dokploy/server"; @@ -64,6 +65,8 @@ import { apiSaveGitlabProvider, apiSaveGitProvider, apiUpdateApplication, + apiUpsertApplicationEnv, + apiUpsertApplicationEnvResponse, applications, environments, projects, @@ -369,6 +372,79 @@ export const applicationRouter = createTRPCRouter({ resourceName: application.appName, }); }), + env: createTRPCRouter({ + upsert: protectedProcedure + .input(apiUpsertApplicationEnv) + .output(apiUpsertApplicationEnvResponse) + .mutation(async ({ input, ctx }) => { + await checkServicePermissionAndAccess( + ctx, + input.applicationId, + input.redeploy + ? { + envVars: ["write"], + deployment: ["create"], + } + : { + envVars: ["write"], + }, + ); + + const result = await upsertApplicationEnvironment(input); + let redeployed = false; + + if (!result.dryRun && result.changed) { + const application = await findApplicationById(input.applicationId); + + await audit(ctx, { + action: "update", + resourceType: "application", + resourceId: application.applicationId, + resourceName: application.appName, + }); + + if (input.redeploy) { + const jobData: DeploymentJob = { + applicationId: input.applicationId, + titleLog: "Rebuild deployment", + descriptionLog: "Environment variables updated", + type: "redeploy", + applicationType: "application", + server: !!application.serverId, + }; + + if (IS_CLOUD && application.serverId) { + jobData.serverId = application.serverId; + deploy(jobData).catch((error) => { + console.error("Background deployment failed:", error); + }); + } else { + await myQueue.add( + "deployments", + { ...jobData }, + { + removeOnComplete: true, + removeOnFail: true, + }, + ); + } + + await audit(ctx, { + action: "rebuild", + resourceType: "application", + resourceId: application.applicationId, + resourceName: application.appName, + }); + redeployed = true; + } + } + + return { + ...result, + redeployed, + }; + }), + }), saveEnvironment: protectedProcedure .input(apiSaveEnvironmentVariables) .mutation(async ({ input, ctx }) => { diff --git a/packages/server/src/db/schema/application.ts b/packages/server/src/db/schema/application.ts index 59dfd37161..afcf6275fd 100644 --- a/packages/server/src/db/schema/application.ts +++ b/packages/server/src/db/schema/application.ts @@ -530,6 +530,43 @@ export const apiSaveEnvironmentVariables = createSchema }) .required(); +const ENV_VARIABLE_NAME_REGEX = /^[A-Za-z_][A-Za-z0-9_]*$/; + +export const apiUpsertApplicationEnv = z.object({ + applicationId: z.string().min(1), + variables: z + .record( + z + .string() + .regex( + ENV_VARIABLE_NAME_REGEX, + "Environment variable names must start with a letter or underscore and contain only letters, numbers, and underscores", + ), + z.string(), + ) + .refine((variables) => Object.keys(variables).length > 0, { + message: "At least one environment variable is required", + }), + redeploy: z.boolean().optional(), + dryRun: z.boolean().optional(), + expectedRevision: z.string().optional(), +}); + +export const apiUpsertApplicationEnvResponse = z.object({ + applicationId: z.string(), + changed: z.boolean(), + revision: z.string(), + dryRun: z.boolean(), + redeployed: z.boolean(), + variables: z.array( + z.object({ + name: z.string(), + action: z.enum(["created", "updated", "unchanged"]), + secret: z.boolean(), + }), + ), +}); + export const apiFindMonitoringStats = z.object({ appName: z.string().min(1), }); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 7bda4615ad..4ecbd1a950 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -100,6 +100,7 @@ export * from "./utils/docker/compose/volume"; export * from "./utils/docker/domain"; export * from "./utils/docker/types"; export * from "./utils/docker/utils"; +export * from "./utils/env-upsert"; export * from "./utils/filesystem/directory"; export * from "./utils/filesystem/ssh"; export * from "./utils/git-branch-validation"; diff --git a/packages/server/src/services/application.ts b/packages/server/src/services/application.ts index c8ebb3be77..40a6398961 100644 --- a/packages/server/src/services/application.ts +++ b/packages/server/src/services/application.ts @@ -2,6 +2,7 @@ import { docker } from "@dokploy/server/constants"; import { db } from "@dokploy/server/db"; import { type apiCreateApplication, + type apiUpsertApplicationEnv, applications, buildAppName, } from "@dokploy/server/db/schema"; @@ -31,6 +32,10 @@ import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; import type { z } from "zod"; import { encodeBase64 } from "../utils/docker/utils"; +import { + getApplicationEnvRevision, + upsertEnvVariables, +} from "../utils/env-upsert"; import { getDokployUrl } from "./admin"; import { createDeployment, @@ -146,6 +151,43 @@ export const updateApplication = async ( return application[0]; }; +export const upsertApplicationEnvironment = async ( + input: z.infer, +) => { + const application = await findApplicationById(input.applicationId); + const currentEnv = application.env ?? ""; + const currentRevision = getApplicationEnvRevision( + input.applicationId, + currentEnv, + ); + + if (input.expectedRevision && input.expectedRevision !== currentRevision) { + throw new TRPCError({ + code: "CONFLICT", + message: "Application environment revision does not match", + }); + } + + const result = upsertEnvVariables(currentEnv, input.variables); + const nextRevision = result.changed + ? getApplicationEnvRevision(input.applicationId, result.env) + : currentRevision; + + if (!input.dryRun && result.changed) { + await updateApplication(input.applicationId, { + env: result.env, + }); + } + + return { + applicationId: input.applicationId, + changed: result.changed, + revision: input.dryRun ? currentRevision : nextRevision, + dryRun: input.dryRun ?? false, + variables: result.variables, + }; +}; + export const updateApplicationStatus = async ( applicationId: string, applicationStatus: Application["applicationStatus"], diff --git a/packages/server/src/utils/env-upsert.ts b/packages/server/src/utils/env-upsert.ts new file mode 100644 index 0000000000..8c44ba8a77 --- /dev/null +++ b/packages/server/src/utils/env-upsert.ts @@ -0,0 +1,130 @@ +import { createHmac } from "node:crypto"; +import { parse } from "dotenv"; +import { betterAuthSecret } from "../lib/auth-secret"; + +export type EnvUpsertAction = "created" | "updated" | "unchanged"; + +export type EnvUpsertVariableResult = { + name: string; + action: EnvUpsertAction; + secret: boolean; +}; + +export type EnvUpsertResult = { + env: string; + changed: boolean; + variables: EnvUpsertVariableResult[]; +}; + +const ENV_ASSIGNMENT_REGEX = + /^(\s*(?:export\s+)?)([A-Za-z_][A-Za-z0-9_]*)(\s*=\s*)(.*)$/; + +const SECRET_NAME_REGEX = + /(^|_)(AUTH|CREDENTIAL|KEY|PASS|PASSWORD|PRIVATE|SECRET|TOKEN|WEBHOOK)($|_)/i; + +export const isSecretEnvName = (name: string) => SECRET_NAME_REGEX.test(name); + +export const getApplicationEnvRevision = ( + applicationId: string, + env: string | null | undefined, +) => + `env:${createHmac("sha256", betterAuthSecret) + .update(applicationId) + .update("\0") + .update(env ?? "") + .digest("base64url") + .slice(0, 32)}`; + +const serializeEnvValue = (value: string) => { + if (value === "") { + return ""; + } + + if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value)) { + return value; + } + + return `"${value + .replace(/\\/g, "\\\\") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/"/g, '\\"')}"`; +}; + +const splitEnvLines = (env: string) => { + const normalized = env.replace(/\r\n/g, "\n"); + const trailingNewline = normalized.endsWith("\n"); + const lines = normalized.length > 0 ? normalized.split("\n") : []; + + if (trailingNewline) { + lines.pop(); + } + + return { + lines, + trailingNewline, + }; +}; + +export const upsertEnvVariables = ( + currentEnv: string | null | undefined, + variables: Record, +): EnvUpsertResult => { + const env = currentEnv ?? ""; + const currentValues = parse(env); + const { lines, trailingNewline } = splitEnvLines(env); + const lastLineByName = new Map(); + + lines.forEach((line, index) => { + const match = ENV_ASSIGNMENT_REGEX.exec(line); + if (match) { + lastLineByName.set(match[2]!, index); + } + }); + + let changed = false; + const variableResults: EnvUpsertVariableResult[] = []; + + for (const [name, value] of Object.entries(variables)) { + const currentValue = currentValues[name]; + const action = + currentValue === undefined + ? "created" + : currentValue === value + ? "unchanged" + : "updated"; + + variableResults.push({ + name, + action, + secret: isSecretEnvName(name), + }); + + if (action === "unchanged") { + continue; + } + + changed = true; + const serializedValue = serializeEnvValue(value); + const existingLineIndex = lastLineByName.get(name); + + if (existingLineIndex !== undefined) { + const match = ENV_ASSIGNMENT_REGEX.exec(lines[existingLineIndex]!); + if (match) { + lines[existingLineIndex] = + `${match[1]!}${name}${match[3]!}${serializedValue}`; + } + continue; + } + + lines.push(`${name}=${serializedValue}`); + } + + const nextEnv = lines.join("\n"); + + return { + env: trailingNewline && nextEnv.length > 0 ? `${nextEnv}\n` : nextEnv, + changed, + variables: variableResults, + }; +}; From c06bd6fba96f5349241d691f016743e4130c3244 Mon Sep 17 00:00:00 2001 From: agentHits <140916359+agentHits@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:39:45 +0300 Subject: [PATCH 2/5] fix: serve application env upsert api routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Что: - Добавил прямой Next API handler для safe application env upsert и slash/compatibility routes `/api/application/env/upsert` и `/api/application.env.upsert`. - Подключил exact-match обработку этих routes в OpenAPI catch-all и custom HTTP server, сохранив auth, permission checks, dry-run и redeploy gating. Зачем: - MCP и внешние клиенты должны вызывать безопасный partial env upsert без full environment replacement и без возврата raw secret values. Риски: - Route selection в custom server учитывает exact-match request path и `x-forwarded-uri` для self-hosted proxy setups; auth, permissions и schema validation остаются обязательными. Проверки: - Команды и результаты: `git diff --cached --check` прошел; `corepack pnpm --filter=dokploy exec vitest run --config __test__/vitest.config.ts __test__/env/upsert.test.ts __test__/env/application-env-upsert.test.ts` прошел, 2 файла и 7 тестов; `CI=true corepack pnpm exec biome check ...` прошел на 10 scoped files с существующими warnings/infos; `corepack pnpm --filter=dokploy run build-server` прошел; независимый Agent Flow QA вернул pass-with-risks без блокирующих findings. - Ограничения: full typecheck и generate:openapi в core не прошли из-за существующих unrelated project/dependency failures; live HTTP smoke через custom server не запускался. What: - Added a direct Next API handler for safe application env upsert and slash/compatibility routes `/api/application/env/upsert` and `/api/application.env.upsert`. - Wired exact-match handling for those routes through the OpenAPI catch-all and custom HTTP server while preserving auth, permission checks, dry-run, and redeploy gating. Why: - MCP and external clients need to call safe partial env upsert without full environment replacement and without returning raw secret values. Risks: - Custom-server route selection considers exact-match request path and `x-forwarded-uri` for self-hosted proxy setups; auth, permissions, and schema validation remain mandatory. Checks: - Commands and results: `git diff --cached --check` passed; `corepack pnpm --filter=dokploy exec vitest run --config __test__/vitest.config.ts __test__/env/upsert.test.ts __test__/env/application-env-upsert.test.ts` passed, 2 files and 7 tests; `CI=true corepack pnpm exec biome check ...` passed on 10 scoped files with existing warnings/infos; `corepack pnpm --filter=dokploy run build-server` passed; independent Agent Flow QA returned pass-with-risks with no blocking findings. - Limitations: full core typecheck and generate:openapi did not pass because of existing unrelated project/dependency failures; live HTTP smoke through the custom server was not run. --- apps/dokploy/pages/api/[...trpc].ts | 16 ++ .../pages/api/application.env.upsert.ts | 154 ++++++++++++++++++ .../pages/api/application/env/upsert.ts | 4 + .../dokploy/server/api/routers/application.ts | 6 + apps/dokploy/server/server.ts | 108 ++++++++++++ 5 files changed, 288 insertions(+) create mode 100644 apps/dokploy/pages/api/application.env.upsert.ts create mode 100644 apps/dokploy/pages/api/application/env/upsert.ts diff --git a/apps/dokploy/pages/api/[...trpc].ts b/apps/dokploy/pages/api/[...trpc].ts index 83ff9b0503..20b0904a2f 100644 --- a/apps/dokploy/pages/api/[...trpc].ts +++ b/apps/dokploy/pages/api/[...trpc].ts @@ -3,8 +3,24 @@ import { createOpenApiNextHandler } from "@dokploy/trpc-openapi"; import type { NextApiRequest, NextApiResponse } from "next"; import { appRouter } from "@/server/api/root"; import { createTRPCContext } from "@/server/api/trpc"; +import { handleApplicationEnvUpsert } from "./application.env.upsert"; const handler = async (req: NextApiRequest, res: NextApiResponse) => { + const slashPath = Array.isArray(req.query.trpc) + ? req.query.trpc.join("/") + : req.query.trpc; + const dotPath = Array.isArray(req.query.trpc) + ? req.query.trpc.join(".") + : req.query.trpc; + + if ( + slashPath === "application/env/upsert" || + dotPath === "application.env.upsert" + ) { + await handleApplicationEnvUpsert(req, res); + return; + } + const { session, user } = await validateRequest(req); if (!user || !session) { diff --git a/apps/dokploy/pages/api/application.env.upsert.ts b/apps/dokploy/pages/api/application.env.upsert.ts new file mode 100644 index 0000000000..faa60a2a41 --- /dev/null +++ b/apps/dokploy/pages/api/application.env.upsert.ts @@ -0,0 +1,154 @@ +import { + findApplicationById, + IS_CLOUD, + upsertApplicationEnvironment, + validateRequest, +} from "@dokploy/server"; +import { apiUpsertApplicationEnv } from "@dokploy/server/db/schema"; +import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission"; +import { TRPCError } from "@trpc/server"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { ZodError } from "zod"; +import { audit } from "@/server/api/utils/audit"; +import type { DeploymentJob } from "@/server/queues/queue-types"; +import { myQueue } from "@/server/queues/queueSetup"; +import { deploy } from "@/server/utils/deploy"; + +const getErrorStatus = (error: unknown) => { + if (error instanceof ZodError) { + return 400; + } + + if (!(error instanceof TRPCError)) { + return 500; + } + + switch (error.code) { + case "BAD_REQUEST": + return 400; + case "UNAUTHORIZED": + return 403; + case "NOT_FOUND": + return 404; + case "CONFLICT": + return 409; + default: + return 500; + } +}; + +const getErrorMessage = (error: unknown) => { + if (error instanceof ZodError) { + return "Invalid request body"; + } + + if (error instanceof Error) { + return error.message; + } + + return "Internal server error"; +}; + +export const handleApplicationEnvUpsert = async ( + req: NextApiRequest, + res: NextApiResponse, +) => { + if (req.method !== "POST") { + res.setHeader("Allow", "POST"); + res.status(405).json({ message: "Method Not Allowed" }); + return; + } + + try { + const { session, user } = await validateRequest(req); + + if (!user || !session) { + res.status(401).json({ message: "Unauthorized" }); + return; + } + + const ctx = { + session: { + ...session, + activeOrganizationId: session.activeOrganizationId || "", + }, + user: { + ...user, + role: user.role as "owner" | "member" | "admin", + }, + }; + + const input = apiUpsertApplicationEnv.parse(req.body); + + await checkServicePermissionAndAccess( + ctx, + input.applicationId, + input.redeploy + ? { + envVars: ["write"], + deployment: ["create"], + } + : { + envVars: ["write"], + }, + ); + + const result = await upsertApplicationEnvironment(input); + let redeployed = false; + + if (!result.dryRun && result.changed) { + const application = await findApplicationById(input.applicationId); + + await audit(ctx, { + action: "update", + resourceType: "application", + resourceId: application.applicationId, + resourceName: application.appName, + }); + + if (input.redeploy) { + const jobData: DeploymentJob = { + applicationId: input.applicationId, + titleLog: "Rebuild deployment", + descriptionLog: "Environment variables updated", + type: "redeploy", + applicationType: "application", + server: !!application.serverId, + }; + + if (IS_CLOUD && application.serverId) { + jobData.serverId = application.serverId; + deploy(jobData).catch((error) => { + console.error("Background deployment failed:", error); + }); + } else { + await myQueue.add( + "deployments", + { ...jobData }, + { + removeOnComplete: true, + removeOnFail: true, + }, + ); + } + + await audit(ctx, { + action: "rebuild", + resourceType: "application", + resourceId: application.applicationId, + resourceName: application.appName, + }); + redeployed = true; + } + } + + res.status(200).json({ + ...result, + redeployed, + }); + } catch (error) { + res.status(getErrorStatus(error)).json({ message: getErrorMessage(error) }); + } +}; + +export default handleApplicationEnvUpsert; diff --git a/apps/dokploy/pages/api/application/env/upsert.ts b/apps/dokploy/pages/api/application/env/upsert.ts new file mode 100644 index 0000000000..42f68c2aa5 --- /dev/null +++ b/apps/dokploy/pages/api/application/env/upsert.ts @@ -0,0 +1,4 @@ +export { + default, + handleApplicationEnvUpsert, +} from "../../application.env.upsert"; diff --git a/apps/dokploy/server/api/routers/application.ts b/apps/dokploy/server/api/routers/application.ts index 53d008696e..6b39ffa065 100644 --- a/apps/dokploy/server/api/routers/application.ts +++ b/apps/dokploy/server/api/routers/application.ts @@ -374,6 +374,12 @@ export const applicationRouter = createTRPCRouter({ }), env: createTRPCRouter({ upsert: protectedProcedure + .meta({ + openapi: { + path: "/application/env/upsert", + method: "POST", + }, + }) .input(apiUpsertApplicationEnv) .output(apiUpsertApplicationEnvResponse) .mutation(async ({ input, ctx }) => { diff --git a/apps/dokploy/server/server.ts b/apps/dokploy/server/server.ts index 4de4d76897..4222ec9dc4 100644 --- a/apps/dokploy/server/server.ts +++ b/apps/dokploy/server/server.ts @@ -1,3 +1,4 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; import http from "node:http"; import { createDefaultMiddlewares, @@ -14,8 +15,10 @@ import { setupDirectories, } from "@dokploy/server"; import { config } from "dotenv"; +import type { NextApiRequest, NextApiResponse } from "next"; import next from "next"; import packageInfo from "../package.json"; +import { handleApplicationEnvUpsert } from "../pages/api/application.env.upsert"; import { setupDockerContainerLogsWebSocketServer } from "./wss/docker-container-logs"; import { setupDockerContainerTerminalWebSocketServer } from "./wss/docker-container-terminal"; import { setupDockerStatsMonitoringSocketServer } from "./wss/docker-stats"; @@ -39,10 +42,115 @@ if (process.env.NODE_ENV === "production" && !IS_CLOUD) { const app = next({ dev, turbopack: process.env.TURBOPACK === "1" }); const handle = app.getRequestHandler(); + +type NodeNextApiResponse = ServerResponse & { + status: (code: number) => NodeNextApiResponse; + json: (body: unknown) => NodeNextApiResponse; +}; + +const normalizeRequestPath = (value: string) => { + try { + return new URL(value, "http://localhost").pathname.replace(/\/+$/, ""); + } catch { + return ""; + } +}; + +const isApplicationEnvUpsertPath = (value: string) => { + const pathname = normalizeRequestPath(value); + + return ( + pathname === "/api/application/env/upsert" || + pathname === "/api/application.env.upsert" + ); +}; + +const getForwardedUriValues = (req: IncomingMessage) => { + const forwardedUri = req.headers["x-forwarded-uri"]; + + if (Array.isArray(forwardedUri)) { + return forwardedUri; + } + + return typeof forwardedUri === "string" ? [forwardedUri] : []; +}; + +const isApplicationEnvUpsertRequest = (req: IncomingMessage) => + [req.url ?? "/", ...getForwardedUriValues(req)].some((path) => + isApplicationEnvUpsertPath(path), + ); + +const readJsonBody = async (req: IncomingMessage) => + new Promise((resolve, reject) => { + let body = ""; + req.on("data", (chunk) => { + body += chunk; + }); + req.on("end", () => { + if (!body) { + resolve({}); + return; + } + + try { + resolve(JSON.parse(body)); + } catch (error) { + reject(error); + } + }); + req.on("error", reject); + }); + +const handleApplicationEnvUpsertRequest = async ( + req: IncomingMessage, + res: ServerResponse, +) => { + try { + (req as IncomingMessage & { body: unknown }).body = await readJsonBody(req); + } catch { + res.statusCode = 400; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ message: "Invalid request body" })); + return; + } + + const nextResponse = res as NodeNextApiResponse; + + nextResponse.status = (code: number) => { + res.statusCode = code; + return nextResponse; + }; + nextResponse.json = (body: unknown) => { + if (!res.headersSent) { + res.setHeader("Content-Type", "application/json"); + } + res.end(JSON.stringify(body)); + return nextResponse; + }; + + try { + await handleApplicationEnvUpsert( + req as NextApiRequest, + nextResponse as unknown as NextApiResponse, + ); + } catch { + if (!res.headersSent) { + res.statusCode = 500; + res.setHeader("Content-Type", "application/json"); + } + res.end(JSON.stringify({ message: "Internal server error" })); + } +}; + void app.prepare().then(async () => { try { console.log("Running DokployVersion: ", packageInfo.version); const server = http.createServer((req, res) => { + if (isApplicationEnvUpsertRequest(req)) { + void handleApplicationEnvUpsertRequest(req, res); + return; + } + handle(req, res); }); From afef2ab06d551eb4f8d9763b1d1688549a8365df Mon Sep 17 00:00:00 2001 From: agentHits <140916359+agentHits@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:25:31 +0300 Subject: [PATCH 3/5] fix: preserve and reveal environment secrets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Что: - Перенесены ENV-фиксы из AgentHits-Dev в application env upsert ветку: default reads редактируют секретные env-поля, reveal endpoints возвращают raw values только после envVars read access, а write/update пути сохраняют текущие значения при redacted placeholder input. - UI Environment теперь раскрывает реальные значения через reveal по клику на eye и использует revealed values как baseline формы. - Добавлены focused tests для redaction helpers и reveal access guard. Зачем: - Пользователь должен видеть реальные ENV только по явному reveal, а MCP/API full update не должен перезаписывать сохраненные секреты строкой __DOKPLOY_REDACTED_SECRET__. Риски: - App/server typecheck в этой ветке остается заблокирован существующим TypeScript 6 baseline; scoped tests и lint по измененным файлам прошли. Проверки: - Команды и результаты: corepack pnpm --dir apps/dokploy exec vitest --config __test__/vitest.config.ts run __test__/security/env-redaction.test.ts __test__/security/service-environment.test.ts __test__/env/upsert.test.ts __test__/env/application-env-upsert.test.ts -> 4 files / 13 tests passed; corepack pnpm exec biome check <17 scoped files> -> passed; git diff --check -> passed. - Ограничения: corepack pnpm --filter=@dokploy/server run typecheck и corepack pnpm --filter=dokploy run typecheck fail on TS5101 baseUrl deprecation with TypeScript 6.0.3; tsc --noEmit --ignoreDeprecations 6.0 still fails on existing unrelated baseline, with no filtered errors in new redaction/reveal files. What: - Ported ENV fixes from AgentHits-Dev into the application env upsert branch: default reads redact secret env fields, reveal endpoints return raw values only after envVars read access, and write/update paths preserve current values when redacted placeholder input is submitted. - The Environment UI now reveals real values through reveal on eye click and uses revealed values as the form baseline. - Added focused tests for redaction helpers and the reveal access guard. Why: - The user needs to see real ENV only on explicit reveal, while MCP/API full updates must not overwrite stored secrets with __DOKPLOY_REDACTED_SECRET__. Risks: - App/server typecheck in this branch remains blocked by the existing TypeScript 6 baseline; scoped tests and lint on changed files passed. Checks: - Commands and results: corepack pnpm --dir apps/dokploy exec vitest --config __test__/vitest.config.ts run __test__/security/env-redaction.test.ts __test__/security/service-environment.test.ts __test__/env/upsert.test.ts __test__/env/application-env-upsert.test.ts -> 4 files / 13 tests passed; corepack pnpm exec biome check <17 scoped files> -> passed; git diff --check -> passed. - Limitations: corepack pnpm --filter=@dokploy/server run typecheck and corepack pnpm --filter=dokploy run typecheck fail on TS5101 baseUrl deprecation with TypeScript 6.0.3; tsc --noEmit --ignoreDeprecations 6.0 still fails on existing unrelated baseline, with no filtered errors in new redaction/reveal files. --- .../__test__/security/env-redaction.test.ts | 88 ++++++++++++ .../security/service-environment.test.ts | 68 +++++++++ .../environment/show-environment.tsx | 66 ++++++++- .../application/environment/show.tsx | 68 +++++++-- apps/dokploy/components/ui/secrets.tsx | 18 ++- .../dokploy/server/api/routers/application.ts | 129 ++++++++++++----- apps/dokploy/server/api/routers/compose.ts | 49 ++++++- .../dokploy/server/api/routers/environment.ts | 5 +- apps/dokploy/server/api/routers/libsql.ts | 47 +++++- apps/dokploy/server/api/routers/mariadb.ts | 48 ++++++- apps/dokploy/server/api/routers/mongo.ts | 47 +++++- apps/dokploy/server/api/routers/mysql.ts | 48 ++++++- apps/dokploy/server/api/routers/postgres.ts | 47 +++++- apps/dokploy/server/api/routers/project.ts | 8 +- apps/dokploy/server/api/routers/redis.ts | 47 +++++- .../server/api/utils/service-environment.ts | 45 ++++++ .../server/src/utils/security/redaction.ts | 135 ++++++++++++++++++ 17 files changed, 858 insertions(+), 105 deletions(-) create mode 100644 apps/dokploy/__test__/security/env-redaction.test.ts create mode 100644 apps/dokploy/__test__/security/service-environment.test.ts create mode 100644 apps/dokploy/server/api/utils/service-environment.ts create mode 100644 packages/server/src/utils/security/redaction.ts diff --git a/apps/dokploy/__test__/security/env-redaction.test.ts b/apps/dokploy/__test__/security/env-redaction.test.ts new file mode 100644 index 0000000000..eb0f832255 --- /dev/null +++ b/apps/dokploy/__test__/security/env-redaction.test.ts @@ -0,0 +1,88 @@ +import { + preserveSecretPlaceholderFields, + REDACTED_SECRET_VALUE, + redactDatabaseServiceSecrets, + redactDeployableServiceSecrets, + redactSecretFields, +} from "@dokploy/server/utils/security/redaction"; +import { describe, expect, it } from "vitest"; + +describe("env secret redaction helpers", () => { + it("preserves stored values when an update contains redacted placeholders", () => { + const next = preserveSecretPlaceholderFields( + { + env: REDACTED_SECRET_VALUE, + buildArgs: "[REDACTED]", + buildSecrets: "NPM_TOKEN=new", + name: "api", + }, + { + env: "TOKEN=old", + buildArgs: "ARG_TOKEN=old", + buildSecrets: "NPM_TOKEN=old", + name: "old-api", + }, + ["env", "buildArgs", "buildSecrets"], + ); + + expect(next).toEqual({ + env: "TOKEN=old", + buildArgs: "ARG_TOKEN=old", + buildSecrets: "NPM_TOKEN=new", + name: "api", + }); + }); + + it("redacts deployable service environment fields on normal reads", () => { + const redacted = redactDeployableServiceSecrets({ + env: "TOKEN=secret", + previewEnv: "TOKEN=preview-secret", + buildArgs: "NPM_TOKEN=secret", + buildSecrets: "NPM_TOKEN=secret", + password: "registry-password", + name: "api", + }); + + expect(redacted).toMatchObject({ + env: REDACTED_SECRET_VALUE, + previewEnv: REDACTED_SECRET_VALUE, + buildArgs: REDACTED_SECRET_VALUE, + buildSecrets: REDACTED_SECRET_VALUE, + password: REDACTED_SECRET_VALUE, + name: "api", + }); + }); + + it("redacts database env and password fields on normal reads", () => { + const redacted = redactDatabaseServiceSecrets({ + env: "PGSSLMODE=require", + databasePassword: "database-password", + databaseRootPassword: "root-password", + appName: "postgres", + }); + + expect(redacted).toMatchObject({ + env: REDACTED_SECRET_VALUE, + databasePassword: REDACTED_SECRET_VALUE, + databaseRootPassword: REDACTED_SECRET_VALUE, + appName: "postgres", + }); + }); + + it("can redact compose file content with the generic field helper", () => { + const redacted = redactSecretFields( + { + composeFile: "services:\n api:\n environment:\n TOKEN: secret", + env: "TOKEN=secret", + name: "stack", + }, + ["composeFile", "env"], + ); + + expect(redacted).toEqual({ + composeFile: REDACTED_SECRET_VALUE, + env: REDACTED_SECRET_VALUE, + name: "stack", + }); + }); +}); diff --git a/apps/dokploy/__test__/security/service-environment.test.ts b/apps/dokploy/__test__/security/service-environment.test.ts new file mode 100644 index 0000000000..e34ff40fc5 --- /dev/null +++ b/apps/dokploy/__test__/security/service-environment.test.ts @@ -0,0 +1,68 @@ +import type { TRPCError } from "@trpc/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + checkServicePermissionAndAccess: vi.fn(), +})); + +vi.mock("@dokploy/server/services/permission", () => ({ + checkServicePermissionAndAccess: mocks.checkServicePermissionAndAccess, +})); + +const { assertServiceEnvironmentReadAccess } = await import( + "../../server/api/utils/service-environment" +); + +const createContext = (organizationId = "org-1") => + ({ + session: { + activeOrganizationId: organizationId, + }, + }) as never; + +const service = (organizationId = "org-1") => ({ + env: "TOKEN=secret", + environment: { + project: { + organizationId, + }, + }, +}); + +describe("service environment reveal access", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.checkServicePermissionAndAccess.mockResolvedValue(undefined); + }); + + it("requires envVars read permission and returns the service in the active organization", async () => { + const result = await assertServiceEnvironmentReadAccess( + createContext(), + "service-1", + async () => service(), + "service", + ); + + expect(result.env).toBe("TOKEN=secret"); + expect(mocks.checkServicePermissionAndAccess).toHaveBeenCalledWith( + expect.anything(), + "service-1", + { + envVars: ["read"], + }, + ); + }); + + it("rejects reveal when the service belongs to another organization", async () => { + await expect( + assertServiceEnvironmentReadAccess( + createContext("org-1"), + "service-1", + async () => service("org-2"), + "service", + ), + ).rejects.toMatchObject({ + code: "UNAUTHORIZED", + } satisfies Partial); + }); +}); diff --git a/apps/dokploy/components/dashboard/application/environment/show-environment.tsx b/apps/dokploy/components/dashboard/application/environment/show-environment.tsx index f5327818f4..8c4873cc2e 100644 --- a/apps/dokploy/components/dashboard/application/environment/show-environment.tsx +++ b/apps/dokploy/components/dashboard/application/environment/show-environment.tsx @@ -54,6 +54,9 @@ export const ShowEnvironment = ({ id, type }: Props) => { ? queryMap[type]() : api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }); const [isEnvVisible, setIsEnvVisible] = useState(true); + const [revealedEnvironment, setRevealedEnvironment] = useState( + null, + ); const mutationMap = { compose: () => api.compose.saveEnvironment.useMutation(), @@ -67,6 +70,19 @@ export const ShowEnvironment = ({ id, type }: Props) => { const { mutateAsync, isPending } = mutationMap[type] ? mutationMap[type]() : api.mongo.saveEnvironment.useMutation(); + const revealMutationMap = { + compose: () => api.compose.revealEnvironment.useMutation(), + libsql: () => api.libsql.revealEnvironment.useMutation(), + mariadb: () => api.mariadb.revealEnvironment.useMutation(), + mongo: () => api.mongo.revealEnvironment.useMutation(), + mysql: () => api.mysql.revealEnvironment.useMutation(), + postgres: () => api.postgres.revealEnvironment.useMutation(), + redis: () => api.redis.revealEnvironment.useMutation(), + }; + const { mutateAsync: revealEnvironment, isPending: isRevealingEnvironment } = + revealMutationMap[type] + ? revealMutationMap[type]() + : api.mongo.revealEnvironment.useMutation(); const form = useForm({ defaultValues: { @@ -77,10 +93,12 @@ export const ShowEnvironment = ({ id, type }: Props) => { // Watch form value const currentEnvironment = form.watch("environment"); - const hasChanges = currentEnvironment !== (data?.env || ""); + const baselineEnvironment = revealedEnvironment ?? data?.env ?? ""; + const hasChanges = currentEnvironment !== baselineEnvironment; useEffect(() => { if (data) { + setRevealedEnvironment(null); form.reset({ environment: data.env || "", }); @@ -100,6 +118,7 @@ export const ShowEnvironment = ({ id, type }: Props) => { }) .then(async () => { toast.success("Environments Added"); + setRevealedEnvironment(null); await refetch(); }) .catch(() => { @@ -109,10 +128,39 @@ export const ShowEnvironment = ({ id, type }: Props) => { const handleCancel = () => { form.reset({ - environment: data?.env || "", + environment: baselineEnvironment, }); }; + const handleReveal = async () => { + if (revealedEnvironment !== null) { + return; + } + if (hasChanges) { + toast.error("Save or cancel changes before revealing environment"); + throw new Error("Unsaved environment changes"); + } + + try { + const values = await revealEnvironment({ + composeId: id || "", + libsqlId: id || "", + mariadbId: id || "", + mongoId: id || "", + mysqlId: id || "", + postgresId: id || "", + redisId: id || "", + }); + setRevealedEnvironment(values.env); + form.reset({ + environment: values.env, + }); + } catch (error) { + toast.error("Error revealing environment"); + throw error; + } + }; + // Add keyboard shortcut for Ctrl+S/Cmd+S useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -145,9 +193,19 @@ export const ShowEnvironment = ({ id, type }: Props) => { { + if (!pressed) { + try { + await handleReveal(); + } catch { + return; + } + } + setIsEnvVisible(pressed); + }} + disabled={isRevealingEnvironment} > {isEnvVisible ? ( diff --git a/apps/dokploy/components/dashboard/application/environment/show.tsx b/apps/dokploy/components/dashboard/application/environment/show.tsx index fb5fc18a7d..1d09ebd8bd 100644 --- a/apps/dokploy/components/dashboard/application/environment/show.tsx +++ b/apps/dokploy/components/dashboard/application/environment/show.tsx @@ -1,5 +1,5 @@ import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; @@ -25,6 +25,10 @@ const addEnvironmentSchema = z.object({ }); type EnvironmentSchema = z.infer; +type RevealedEnvironment = Pick< + EnvironmentSchema, + "env" | "buildArgs" | "buildSecrets" +>; interface Props { applicationId: string; @@ -35,6 +39,10 @@ export const ShowEnvironment = ({ applicationId }: Props) => { const canWrite = permissions?.envVars.write ?? false; const { mutateAsync, isPending } = api.application.saveEnvironment.useMutation(); + const { mutateAsync: revealEnvironment, isPending: isRevealingEnvironment } = + api.application.revealEnvironment.useMutation(); + const [revealedEnvironment, setRevealedEnvironment] = + useState(null); const { data, refetch } = api.application.one.useQuery( { @@ -60,14 +68,21 @@ export const ShowEnvironment = ({ applicationId }: Props) => { const currentBuildArgs = form.watch("buildArgs"); const currentBuildSecrets = form.watch("buildSecrets"); const currentCreateEnvFile = form.watch("createEnvFile"); + const baselineEnvironment = { + env: revealedEnvironment?.env ?? data?.env ?? "", + buildArgs: revealedEnvironment?.buildArgs ?? data?.buildArgs ?? "", + buildSecrets: revealedEnvironment?.buildSecrets ?? data?.buildSecrets ?? "", + createEnvFile: data?.createEnvFile ?? true, + }; const hasChanges = - currentEnv !== (data?.env || "") || - currentBuildArgs !== (data?.buildArgs || "") || - currentBuildSecrets !== (data?.buildSecrets || "") || - currentCreateEnvFile !== (data?.createEnvFile ?? true); + currentEnv !== baselineEnvironment.env || + currentBuildArgs !== baselineEnvironment.buildArgs || + currentBuildSecrets !== baselineEnvironment.buildSecrets || + currentCreateEnvFile !== baselineEnvironment.createEnvFile; useEffect(() => { if (data) { + setRevealedEnvironment(null); form.reset({ env: data.env || "", buildArgs: data.buildArgs || "", @@ -87,6 +102,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => { }) .then(async () => { toast.success("Environments Added"); + setRevealedEnvironment(null); await refetch(); }) .catch(() => { @@ -96,13 +112,37 @@ export const ShowEnvironment = ({ applicationId }: Props) => { const handleCancel = () => { form.reset({ - env: data?.env || "", - buildArgs: data?.buildArgs || "", - buildSecrets: data?.buildSecrets || "", - createEnvFile: data?.createEnvFile ?? true, + env: baselineEnvironment.env, + buildArgs: baselineEnvironment.buildArgs, + buildSecrets: baselineEnvironment.buildSecrets, + createEnvFile: baselineEnvironment.createEnvFile, }); }; + const handleReveal = async () => { + if (revealedEnvironment) { + return; + } + if (hasChanges) { + toast.error("Save or cancel changes before revealing environment"); + throw new Error("Unsaved environment changes"); + } + + try { + const values = await revealEnvironment({ applicationId }); + setRevealedEnvironment(values); + form.reset({ + env: values.env, + buildArgs: values.buildArgs, + buildSecrets: values.buildSecrets, + createEnvFile: data?.createEnvFile ?? true, + }); + } catch (error) { + toast.error("Error revealing environment"); + throw error; + } + }; + // Add keyboard shortcut for Ctrl+S/Cmd+S useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -139,6 +179,8 @@ export const ShowEnvironment = ({ applicationId }: Props) => { } placeholder={["NODE_ENV=production", "PORT=3000"].join("\n")} + isRevealing={isRevealingEnvironment} + onReveal={handleReveal} /> {data?.buildType === "dockerfile" && ( { target="_blank" rel="noopener noreferrer" > - here + Docker build variables . } placeholder="NPM_TOKEN=xyz" + isRevealing={isRevealingEnvironment} + onReveal={handleReveal} /> )} {data?.buildType === "dockerfile" && ( @@ -176,12 +220,14 @@ export const ShowEnvironment = ({ applicationId }: Props) => { target="_blank" rel="noopener noreferrer" > - here + Docker build secrets . } placeholder="NPM_TOKEN=xyz" + isRevealing={isRevealingEnvironment} + onReveal={handleReveal} /> )} {data?.buildType === "dockerfile" && ( diff --git a/apps/dokploy/components/ui/secrets.tsx b/apps/dokploy/components/ui/secrets.tsx index 9e0ba194f7..9df385f83c 100644 --- a/apps/dokploy/components/ui/secrets.tsx +++ b/apps/dokploy/components/ui/secrets.tsx @@ -21,12 +21,25 @@ interface Props { title: string; description: ReactNode; placeholder: string; + isRevealing?: boolean; + onReveal?: () => Promise | void; } export const Secrets = (props: Props) => { const [isVisible, setIsVisible] = useState(true); const form = useFormContext>(); + const handleVisibilityChange = async (pressed: boolean) => { + if (!pressed && props.onReveal) { + try { + await props.onReveal(); + } catch { + return; + } + } + setIsVisible(pressed); + }; + return ( <> @@ -36,9 +49,10 @@ export const Secrets = (props: Props) => { {isVisible ? ( diff --git a/apps/dokploy/server/api/routers/application.ts b/apps/dokploy/server/api/routers/application.ts index 6b39ffa065..d95125782f 100644 --- a/apps/dokploy/server/api/routers/application.ts +++ b/apps/dokploy/server/api/routers/application.ts @@ -38,6 +38,10 @@ import { checkServicePermissionAndAccess, findMemberByUserId, } from "@dokploy/server/services/permission"; +import { + preserveSecretPlaceholderFields, + redactDeployableServiceSecrets, +} from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import { nanoid } from "nanoid"; @@ -49,6 +53,7 @@ import { withPermission, } from "@/server/api/trpc"; import { audit } from "@/server/api/utils/audit"; +import { assertServiceEnvironmentReadAccess } from "@/server/api/utils/service-environment"; import { apiCreateApplication, apiDeployApplication, @@ -188,12 +193,29 @@ export const applicationRouter = createTRPCRouter({ } return { - ...application, + ...redactDeployableServiceSecrets(application), hasGitProviderAccess, unauthorizedProvider, }; }), + revealEnvironment: protectedProcedure + .input(apiFindOneApplication) + .mutation(async ({ input, ctx }) => { + const application = await assertServiceEnvironmentReadAccess( + ctx, + input.applicationId, + () => findApplicationById(input.applicationId), + "application", + ); + + return { + env: application.env ?? "", + buildArgs: application.buildArgs ?? "", + buildSecrets: application.buildSecrets ?? "", + }; + }), + reload: protectedProcedure .input(apiReloadApplication) .mutation(async ({ input, ctx }) => { @@ -457,18 +479,26 @@ export const applicationRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.applicationId, { envVars: ["write"], }); - await updateApplication(input.applicationId, { - env: input.env, - buildArgs: input.buildArgs, - buildSecrets: input.buildSecrets, - createEnvFile: input.createEnvFile, - }); - const application = await findApplicationById(input.applicationId); + const currentApplication = await findApplicationById(input.applicationId); + const application = await updateApplication( + input.applicationId, + preserveSecretPlaceholderFields( + { + env: input.env, + buildArgs: input.buildArgs, + buildSecrets: input.buildSecrets, + createEnvFile: input.createEnvFile, + }, + currentApplication, + ["env", "buildArgs", "buildSecrets"], + ), + ); await audit(ctx, { action: "update", resourceType: "application", - resourceId: application.applicationId, - resourceName: application.appName, + resourceId: + application?.applicationId ?? currentApplication.applicationId, + resourceName: application?.appName ?? currentApplication.appName, }); return true; }), @@ -611,20 +641,27 @@ export const applicationRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.applicationId, { service: ["create"], }); - await updateApplication(input.applicationId, { - dockerImage: input.dockerImage, - username: input.username, - password: input.password, - sourceType: "docker", - applicationStatus: "idle", - registryUrl: input.registryUrl, - }); - const application = await findApplicationById(input.applicationId); + const currentApplication = await findApplicationById(input.applicationId); + await updateApplication( + input.applicationId, + preserveSecretPlaceholderFields( + { + dockerImage: input.dockerImage, + username: input.username, + password: input.password, + sourceType: "docker" as const, + applicationStatus: "idle" as const, + registryUrl: input.registryUrl, + }, + currentApplication, + ["password"], + ), + ); await audit(ctx, { action: "update", resourceType: "application", - resourceId: application.applicationId, - resourceName: application.appName, + resourceId: currentApplication.applicationId, + resourceName: currentApplication.appName, }); return true; }), @@ -634,22 +671,29 @@ export const applicationRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.applicationId, { service: ["create"], }); - await updateApplication(input.applicationId, { - customGitBranch: input.customGitBranch, - customGitBuildPath: input.customGitBuildPath, - customGitUrl: input.customGitUrl, - customGitSSHKeyId: input.customGitSSHKeyId, - sourceType: "git", - applicationStatus: "idle", - watchPaths: input.watchPaths, - enableSubmodules: input.enableSubmodules, - }); - const application = await findApplicationById(input.applicationId); + const currentApplication = await findApplicationById(input.applicationId); + await updateApplication( + input.applicationId, + preserveSecretPlaceholderFields( + { + customGitBranch: input.customGitBranch, + customGitBuildPath: input.customGitBuildPath, + customGitUrl: input.customGitUrl, + customGitSSHKeyId: input.customGitSSHKeyId, + sourceType: "git" as const, + applicationStatus: "idle" as const, + watchPaths: input.watchPaths, + enableSubmodules: input.enableSubmodules, + }, + currentApplication, + ["customGitUrl"], + ), + ); await audit(ctx, { action: "update", resourceType: "application", - resourceId: application.applicationId, - resourceName: application.appName, + resourceId: currentApplication.applicationId, + resourceName: currentApplication.appName, }); return true; }), @@ -739,9 +783,20 @@ export const applicationRouter = createTRPCRouter({ } const { applicationId, ...rest } = input; - const updateApp = await updateApplication(applicationId, { - ...rest, - }); + const currentApplication = await findApplicationById(applicationId); + const updateApp = await updateApplication( + applicationId, + preserveSecretPlaceholderFields(rest, currentApplication, [ + "env", + "previewEnv", + "buildArgs", + "buildSecrets", + "previewBuildArgs", + "previewBuildSecrets", + "password", + "customGitUrl", + ]), + ); if (!updateApp) { throw new TRPCError({ diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts index 126e80b1db..66acbf8c8d 100644 --- a/apps/dokploy/server/api/routers/compose.ts +++ b/apps/dokploy/server/api/routers/compose.ts @@ -46,6 +46,11 @@ import { fetchTemplatesList, } from "@dokploy/server/templates/github"; import { processTemplate } from "@dokploy/server/templates/processors"; +import { + preserveSecretPlaceholderFields, + redactDeployableServiceSecrets, + redactSecretFields, +} from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import _ from "lodash"; @@ -80,6 +85,7 @@ import { cancelDeployment, deploy } from "@/server/utils/deploy"; import { generatePassword } from "@/templates/utils"; import { createTRPCRouter, protectedProcedure } from "../trpc"; import { audit } from "../utils/audit"; +import { assertServiceEnvironmentReadAccess } from "../utils/service-environment"; export const composeRouter = createTRPCRouter({ create: protectedProcedure @@ -184,19 +190,44 @@ export const composeRouter = createTRPCRouter({ } return { - ...compose, + ...redactSecretFields(redactDeployableServiceSecrets(compose), [ + "composeFile", + ]), hasGitProviderAccess, unauthorizedProvider, }; }), + revealEnvironment: protectedProcedure + .input(apiFindCompose) + .mutation(async ({ input, ctx }) => { + const compose = await assertServiceEnvironmentReadAccess( + ctx, + input.composeId, + () => findComposeById(input.composeId), + "compose", + ); + + return { + env: compose.env ?? "", + }; + }), + update: protectedProcedure .input(apiUpdateCompose) .mutation(async ({ input, ctx }) => { await checkServicePermissionAndAccess(ctx, input.composeId, { service: ["create"], }); - const updated = await updateCompose(input.composeId, input); + const currentCompose = await findComposeById(input.composeId); + const updated = await updateCompose( + input.composeId, + preserveSecretPlaceholderFields(input, currentCompose, [ + "env", + "composeFile", + "customGitUrl", + ]), + ); await audit(ctx, { action: "update", resourceType: "compose", @@ -211,9 +242,17 @@ export const composeRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.composeId, { envVars: ["write"], }); - const updated = await updateCompose(input.composeId, { - env: input.env, - }); + const currentCompose = await findComposeById(input.composeId); + const updated = await updateCompose( + input.composeId, + preserveSecretPlaceholderFields( + { + env: input.env, + }, + currentCompose, + ["env"], + ), + ); if (!updated) { throw new TRPCError({ diff --git a/apps/dokploy/server/api/routers/environment.ts b/apps/dokploy/server/api/routers/environment.ts index 78a60362e1..72f5574368 100644 --- a/apps/dokploy/server/api/routers/environment.ts +++ b/apps/dokploy/server/api/routers/environment.ts @@ -15,6 +15,7 @@ import { checkPermission, findMemberByUserId, } from "@dokploy/server/services/permission"; +import { preserveSecretPlaceholderFields } from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import { z } from "zod"; @@ -276,7 +277,9 @@ export const environmentRouter = createTRPCRouter({ const environment = await updateEnvironmentById( environmentId, - updateData, + preserveSecretPlaceholderFields(updateData, currentEnvironment, [ + "env", + ]), ); if (environment) { await audit(ctx, { diff --git a/apps/dokploy/server/api/routers/libsql.ts b/apps/dokploy/server/api/routers/libsql.ts index 3fdfcf55b7..441af6e6ee 100644 --- a/apps/dokploy/server/api/routers/libsql.ts +++ b/apps/dokploy/server/api/routers/libsql.ts @@ -24,11 +24,16 @@ import { checkServiceAccess, checkServicePermissionAndAccess, } from "@dokploy/server/services/permission"; +import { + preserveSecretPlaceholderFields, + redactDatabaseServiceSecrets, +} from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { audit } from "@/server/api/utils/audit"; +import { assertServiceEnvironmentReadAccess } from "@/server/api/utils/service-environment"; import { db } from "@/server/db"; import { apiChangeLibsqlStatus, @@ -119,7 +124,22 @@ export const libsqlRouter = createTRPCRouter({ message: "You are not authorized to access this Libsql", }); } - return libsql; + return redactDatabaseServiceSecrets(libsql); + }), + + revealEnvironment: protectedProcedure + .input(apiFindOneLibsql) + .mutation(async ({ input, ctx }) => { + const libsql = await assertServiceEnvironmentReadAccess( + ctx, + input.libsqlId, + () => findLibsqlById(input.libsqlId), + "Libsql", + ); + + return { + env: libsql.env ?? "", + }; }), start: protectedProcedure @@ -345,9 +365,17 @@ export const libsqlRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.libsqlId, { envVars: ["write"], }); - const service = await updateLibsqlById(input.libsqlId, { - env: input.env, - }); + const currentLibsql = await findLibsqlById(input.libsqlId); + const service = await updateLibsqlById( + input.libsqlId, + preserveSecretPlaceholderFields( + { + env: input.env, + }, + currentLibsql, + ["env"], + ), + ); if (!service) { throw new TRPCError({ @@ -402,9 +430,14 @@ export const libsqlRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, libsqlId, { service: ["create"], }); - const libsql = await updateLibsqlById(libsqlId, { - ...rest, - }); + const currentLibsql = await findLibsqlById(libsqlId); + const libsql = await updateLibsqlById( + libsqlId, + preserveSecretPlaceholderFields(rest, currentLibsql, [ + "env", + "databasePassword", + ]), + ); if (!libsql) { throw new TRPCError({ diff --git a/apps/dokploy/server/api/routers/mariadb.ts b/apps/dokploy/server/api/routers/mariadb.ts index 4366e73937..1d701b7e29 100644 --- a/apps/dokploy/server/api/routers/mariadb.ts +++ b/apps/dokploy/server/api/routers/mariadb.ts @@ -30,12 +30,17 @@ import { checkServicePermissionAndAccess, findMemberByUserId, } from "@dokploy/server/services/permission"; +import { + preserveSecretPlaceholderFields, + redactDatabaseServiceSecrets, +} from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { observable } from "@trpc/server/observable"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { audit } from "@/server/api/utils/audit"; +import { assertServiceEnvironmentReadAccess } from "@/server/api/utils/service-environment"; import { apiChangeMariaDBStatus, apiCreateMariaDB, @@ -132,7 +137,22 @@ export const mariadbRouter = createTRPCRouter({ message: "You are not authorized to access this Mariadb", }); } - return mariadb; + return redactDatabaseServiceSecrets(mariadb); + }), + + revealEnvironment: protectedProcedure + .input(apiFindOneMariaDB) + .mutation(async ({ input, ctx }) => { + const mariadb = await assertServiceEnvironmentReadAccess( + ctx, + input.mariadbId, + () => findMariadbById(input.mariadbId), + "Mariadb", + ); + + return { + env: mariadb.env ?? "", + }; }), start: protectedProcedure @@ -315,9 +335,17 @@ export const mariadbRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.mariadbId, { envVars: ["write"], }); - const service = await updateMariadbById(input.mariadbId, { - env: input.env, - }); + const currentMariadb = await findMariadbById(input.mariadbId); + const service = await updateMariadbById( + input.mariadbId, + preserveSecretPlaceholderFields( + { + env: input.env, + }, + currentMariadb, + ["env"], + ), + ); if (!service) { throw new TRPCError({ @@ -372,9 +400,15 @@ export const mariadbRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, mariadbId, { service: ["create"], }); - const service = await updateMariadbById(mariadbId, { - ...rest, - }); + const currentMariadb = await findMariadbById(mariadbId); + const service = await updateMariadbById( + mariadbId, + preserveSecretPlaceholderFields(rest, currentMariadb, [ + "env", + "databasePassword", + "databaseRootPassword", + ]), + ); if (!service) { throw new TRPCError({ diff --git a/apps/dokploy/server/api/routers/mongo.ts b/apps/dokploy/server/api/routers/mongo.ts index de38435b44..0cc9f971ee 100644 --- a/apps/dokploy/server/api/routers/mongo.ts +++ b/apps/dokploy/server/api/routers/mongo.ts @@ -30,11 +30,16 @@ import { checkServicePermissionAndAccess, findMemberByUserId, } from "@dokploy/server/services/permission"; +import { + preserveSecretPlaceholderFields, + redactDatabaseServiceSecrets, +} from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { audit } from "@/server/api/utils/audit"; +import { assertServiceEnvironmentReadAccess } from "@/server/api/utils/service-environment"; import { apiChangeMongoStatus, apiCreateMongo, @@ -136,7 +141,22 @@ export const mongoRouter = createTRPCRouter({ message: "You are not authorized to access this mongo", }); } - return mongo; + return redactDatabaseServiceSecrets(mongo); + }), + + revealEnvironment: protectedProcedure + .input(apiFindOneMongo) + .mutation(async ({ input, ctx }) => { + const mongo = await assertServiceEnvironmentReadAccess( + ctx, + input.mongoId, + () => findMongoById(input.mongoId), + "mongo", + ); + + return { + env: mongo.env ?? "", + }; }), start: protectedProcedure @@ -369,9 +389,17 @@ export const mongoRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.mongoId, { envVars: ["write"], }); - const service = await updateMongoById(input.mongoId, { - env: input.env, - }); + const currentMongo = await findMongoById(input.mongoId); + const service = await updateMongoById( + input.mongoId, + preserveSecretPlaceholderFields( + { + env: input.env, + }, + currentMongo, + ["env"], + ), + ); if (!service) { throw new TRPCError({ @@ -394,9 +422,14 @@ export const mongoRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, mongoId, { service: ["create"], }); - const service = await updateMongoById(mongoId, { - ...rest, - }); + const currentMongo = await findMongoById(mongoId); + const service = await updateMongoById( + mongoId, + preserveSecretPlaceholderFields(rest, currentMongo, [ + "env", + "databasePassword", + ]), + ); if (!service) { throw new TRPCError({ diff --git a/apps/dokploy/server/api/routers/mysql.ts b/apps/dokploy/server/api/routers/mysql.ts index 70c40520a3..9d0a95c5a9 100644 --- a/apps/dokploy/server/api/routers/mysql.ts +++ b/apps/dokploy/server/api/routers/mysql.ts @@ -30,11 +30,16 @@ import { checkServicePermissionAndAccess, findMemberByUserId, } from "@dokploy/server/services/permission"; +import { + preserveSecretPlaceholderFields, + redactDatabaseServiceSecrets, +} from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { audit } from "@/server/api/utils/audit"; +import { assertServiceEnvironmentReadAccess } from "@/server/api/utils/service-environment"; import { apiChangeMySqlStatus, apiCreateMySql, @@ -136,7 +141,22 @@ export const mysqlRouter = createTRPCRouter({ message: "You are not authorized to access this MySQL", }); } - return mysql; + return redactDatabaseServiceSecrets(mysql); + }), + + revealEnvironment: protectedProcedure + .input(apiFindOneMySql) + .mutation(async ({ input, ctx }) => { + const mysql = await assertServiceEnvironmentReadAccess( + ctx, + input.mysqlId, + () => findMySqlById(input.mysqlId), + "MySQL", + ); + + return { + env: mysql.env ?? "", + }; }), start: protectedProcedure @@ -365,9 +385,17 @@ export const mysqlRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.mysqlId, { envVars: ["write"], }); - const service = await updateMySqlById(input.mysqlId, { - env: input.env, - }); + const currentMysql = await findMySqlById(input.mysqlId); + const service = await updateMySqlById( + input.mysqlId, + preserveSecretPlaceholderFields( + { + env: input.env, + }, + currentMysql, + ["env"], + ), + ); if (!service) { throw new TRPCError({ @@ -390,9 +418,15 @@ export const mysqlRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, mysqlId, { service: ["create"], }); - const service = await updateMySqlById(mysqlId, { - ...rest, - }); + const currentMysql = await findMySqlById(mysqlId); + const service = await updateMySqlById( + mysqlId, + preserveSecretPlaceholderFields(rest, currentMysql, [ + "env", + "databasePassword", + "databaseRootPassword", + ]), + ); if (!service) { throw new TRPCError({ diff --git a/apps/dokploy/server/api/routers/postgres.ts b/apps/dokploy/server/api/routers/postgres.ts index 9b1b3330a9..d861257446 100644 --- a/apps/dokploy/server/api/routers/postgres.ts +++ b/apps/dokploy/server/api/routers/postgres.ts @@ -31,11 +31,16 @@ import { checkServicePermissionAndAccess, findMemberByUserId, } from "@dokploy/server/services/permission"; +import { + preserveSecretPlaceholderFields, + redactDatabaseServiceSecrets, +} from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { audit } from "@/server/api/utils/audit"; +import { assertServiceEnvironmentReadAccess } from "@/server/api/utils/service-environment"; import { apiChangePostgresStatus, apiCreatePostgres, @@ -140,7 +145,22 @@ export const postgresRouter = createTRPCRouter({ message: "You are not authorized to access this Postgres", }); } - return postgres; + return redactDatabaseServiceSecrets(postgres); + }), + + revealEnvironment: protectedProcedure + .input(apiFindOnePostgres) + .mutation(async ({ input, ctx }) => { + const postgres = await assertServiceEnvironmentReadAccess( + ctx, + input.postgresId, + () => findPostgresById(input.postgresId), + "Postgres", + ); + + return { + env: postgres.env ?? "", + }; }), start: protectedProcedure @@ -342,9 +362,17 @@ export const postgresRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.postgresId, { envVars: ["write"], }); - const service = await updatePostgresById(input.postgresId, { - env: input.env, - }); + const currentPostgres = await findPostgresById(input.postgresId); + const service = await updatePostgresById( + input.postgresId, + preserveSecretPlaceholderFields( + { + env: input.env, + }, + currentPostgres, + ["env"], + ), + ); if (!service) { throw new TRPCError({ @@ -400,9 +428,14 @@ export const postgresRouter = createTRPCRouter({ service: ["create"], }); - const service = await updatePostgresById(postgresId, { - ...rest, - }); + const currentPostgres = await findPostgresById(postgresId); + const service = await updatePostgresById( + postgresId, + preserveSecretPlaceholderFields(rest, currentPostgres, [ + "env", + "databasePassword", + ]), + ); if (!service) { throw new TRPCError({ diff --git a/apps/dokploy/server/api/routers/project.ts b/apps/dokploy/server/api/routers/project.ts index 2e35aee2a1..f5106aa456 100644 --- a/apps/dokploy/server/api/routers/project.ts +++ b/apps/dokploy/server/api/routers/project.ts @@ -38,6 +38,7 @@ import { checkProjectAccess, findMemberByUserId, } from "@dokploy/server/services/permission"; +import { preserveSecretPlaceholderFields } from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import type { AnyPgColumn } from "drizzle-orm/pg-core"; @@ -764,9 +765,10 @@ export const projectRouter = createTRPCRouter({ await checkPermission(ctx, { projectEnvVars: ["write"] }); } - const project = await updateProjectById(input.projectId, { - ...input, - }); + const project = await updateProjectById( + input.projectId, + preserveSecretPlaceholderFields(input, currentProject, ["env"]), + ); if (project) { await audit(ctx, { diff --git a/apps/dokploy/server/api/routers/redis.ts b/apps/dokploy/server/api/routers/redis.ts index 747cce4a31..31185e1727 100644 --- a/apps/dokploy/server/api/routers/redis.ts +++ b/apps/dokploy/server/api/routers/redis.ts @@ -29,11 +29,16 @@ import { checkServicePermissionAndAccess, findMemberByUserId, } from "@dokploy/server/services/permission"; +import { + preserveSecretPlaceholderFields, + redactDatabaseServiceSecrets, +} from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { audit } from "@/server/api/utils/audit"; +import { assertServiceEnvironmentReadAccess } from "@/server/api/utils/service-environment"; import { apiChangeRedisStatus, apiCreateRedis, @@ -127,7 +132,22 @@ export const redisRouter = createTRPCRouter({ message: "You are not authorized to access this Redis", }); } - return redis; + return redactDatabaseServiceSecrets(redis); + }), + + revealEnvironment: protectedProcedure + .input(apiFindOneRedis) + .mutation(async ({ input, ctx }) => { + const redis = await assertServiceEnvironmentReadAccess( + ctx, + input.redisId, + () => findRedisById(input.redisId), + "Redis", + ); + + return { + env: redis.env ?? "", + }; }), start: protectedProcedure @@ -356,9 +376,17 @@ export const redisRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.redisId, { envVars: ["write"], }); - const updatedRedis = await updateRedisById(input.redisId, { - env: input.env, - }); + const currentRedis = await findRedisById(input.redisId); + const updatedRedis = await updateRedisById( + input.redisId, + preserveSecretPlaceholderFields( + { + env: input.env, + }, + currentRedis, + ["env"], + ), + ); if (!updatedRedis) { throw new TRPCError({ @@ -381,9 +409,14 @@ export const redisRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, redisId, { service: ["create"], }); - const redis = await updateRedisById(redisId, { - ...rest, - }); + const currentRedis = await findRedisById(redisId); + const redis = await updateRedisById( + redisId, + preserveSecretPlaceholderFields(rest, currentRedis, [ + "env", + "databasePassword", + ]), + ); if (!redis) { throw new TRPCError({ diff --git a/apps/dokploy/server/api/utils/service-environment.ts b/apps/dokploy/server/api/utils/service-environment.ts new file mode 100644 index 0000000000..76b01bec35 --- /dev/null +++ b/apps/dokploy/server/api/utils/service-environment.ts @@ -0,0 +1,45 @@ +import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission"; +import { TRPCError } from "@trpc/server"; + +type ServiceEnvironmentContext = Parameters< + typeof checkServicePermissionAndAccess +>[0] & { + session: { + activeOrganizationId: string; + }; +}; + +type ServiceEnvironmentResource = { + environment: { + project: { + organizationId: string; + }; + }; +}; + +export const assertServiceEnvironmentReadAccess = async < + TService extends ServiceEnvironmentResource, +>( + ctx: ServiceEnvironmentContext, + serviceId: string, + findService: () => Promise, + resourceName: string, +) => { + await checkServicePermissionAndAccess(ctx, serviceId, { + envVars: ["read"], + }); + + const service = await findService(); + + if ( + service.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: `You are not authorized to access this ${resourceName}`, + }); + } + + return service; +}; diff --git a/packages/server/src/utils/security/redaction.ts b/packages/server/src/utils/security/redaction.ts new file mode 100644 index 0000000000..b0821ea162 --- /dev/null +++ b/packages/server/src/utils/security/redaction.ts @@ -0,0 +1,135 @@ +export const REDACTED_SECRET_VALUE = "__DOKPLOY_REDACTED_SECRET__"; +const MCP_REDACTED_SECRET_VALUE = "[REDACTED]"; + +export type SecretRecord = Record; + +export const isSecretPlaceholderValue = (value: unknown) => + typeof value === "string" && + (value.includes(REDACTED_SECRET_VALUE) || + value.includes(MCP_REDACTED_SECRET_VALUE)); + +export const redactSecretValue = (value: T) => { + if (value === null || value === undefined || value === "") { + return value; + } + + return REDACTED_SECRET_VALUE; +}; + +export const redactSecretFields = ( + record: T, + fields: readonly string[], +) => { + if (!record) { + return record; + } + + const redacted = { ...record }; + + for (const field of fields) { + if (field in redacted) { + redacted[field] = redactSecretValue(redacted[field]); + } + } + + return redacted as T; +}; + +const redactNestedServerSecrets = (server: T): T => { + if (!server || typeof server !== "object") { + return server; + } + + const redacted = { ...(server as SecretRecord) }; + if ("command" in redacted) { + redacted.command = redactSecretValue(redacted.command); + } + if (redacted.metricsConfig && typeof redacted.metricsConfig === "object") { + const metricsConfig = { ...(redacted.metricsConfig as SecretRecord) }; + if (metricsConfig.server && typeof metricsConfig.server === "object") { + metricsConfig.server = redactSecretFields( + metricsConfig.server as SecretRecord, + ["token"], + ); + } + redacted.metricsConfig = metricsConfig; + } + if (redacted.sshKey && typeof redacted.sshKey === "object") { + redacted.sshKey = redactSecretFields(redacted.sshKey as SecretRecord, [ + "privateKey", + ]); + } + + return redacted as T; +}; + +export const redactDeployableServiceSecrets = < + T extends SecretRecord | null | undefined, +>( + record: T, +) => { + const redacted = redactSecretFields(record, [ + "env", + "previewEnv", + "buildArgs", + "buildSecrets", + "previewBuildArgs", + "previewBuildSecrets", + "password", + "refreshToken", + ]); + if (!redacted) { + return redacted; + } + + const withRelations = { ...redacted }; + for (const key of ["server", "buildServer"]) { + if (key in withRelations) { + withRelations[key] = redactNestedServerSecrets(withRelations[key]); + } + } + return withRelations as T; +}; + +export const redactDatabaseServiceSecrets = < + T extends SecretRecord | null | undefined, +>( + record: T, +) => { + const redacted = redactSecretFields(record, [ + "env", + "databasePassword", + "databaseRootPassword", + ]); + if (!redacted) { + return redacted; + } + + const withRelations = { ...redacted }; + if ("server" in withRelations) { + withRelations.server = redactNestedServerSecrets(withRelations.server); + } + return withRelations as T; +}; + +export const preserveSecretPlaceholderFields = < + TUpdate extends object, + TCurrent extends object, +>( + update: TUpdate, + current: TCurrent, + fields: readonly (keyof TUpdate & keyof TCurrent)[], +) => { + const next: Record = { + ...(update as unknown as Record), + }; + const currentRecord = current as Record; + + for (const field of fields) { + if (isSecretPlaceholderValue(next[field])) { + next[field] = currentRecord[field]; + } + } + + return next as TUpdate; +}; From 91634331877190f8a8db918e74879b266d33de59 Mon Sep 17 00:00:00 2001 From: agentHits <140916359+agentHits@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:42:47 +0300 Subject: [PATCH 4/5] fix: redact compose service response secrets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Что: - Добавлена маскировка credential-части customGitUrl и composeFile/env secrets в обычных compose object responses. - create/update/delete/template compose responses теперь используют единый compose redaction wrapper. - Добавлен тест для credential URL redaction. Зачем: - Исключить возврат raw compose secrets после placeholder-preserving updates. Риски: - Не выявлены; route-level compose coverage в этой ветке ограничена helper/focused tests. Проверки: - Команды и результаты: `corepack pnpm --dir apps/dokploy exec vitest --config __test__/vitest.config.ts run __test__/security/env-redaction.test.ts __test__/security/service-environment.test.ts __test__/env/upsert.test.ts __test__/env/application-env-upsert.test.ts` - passed (4 files / 14 tests); `corepack pnpm exec biome check <16 scoped files>` - passed; `git diff --check` - passed. - Ограничения: repo typecheck не запускался повторно; ранее блокировался baseline TypeScript 6/baseUrl config. What: - Added masking for customGitUrl credential parts and composeFile/env secrets in normal compose object responses. - create/update/delete/template compose responses now use one compose redaction wrapper. - Added coverage for credential URL redaction. Why: - Prevent raw compose secrets from being returned after placeholder-preserving updates. Risks: - None identified; route-level compose coverage on this branch is limited to helper/focused tests. Checks: - Commands and results: `corepack pnpm --dir apps/dokploy exec vitest --config __test__/vitest.config.ts run __test__/security/env-redaction.test.ts __test__/security/service-environment.test.ts __test__/env/upsert.test.ts __test__/env/application-env-upsert.test.ts` - passed (4 files / 14 tests); `corepack pnpm exec biome check <16 scoped files>` - passed; `git diff --check` - passed. - Limitations: repo typecheck was not rerun; it was previously blocked by baseline TypeScript 6/baseUrl config. --- .../__test__/security/env-redaction.test.ts | 10 +++++ apps/dokploy/server/api/routers/compose.ts | 42 +++++++++++++++---- .../server/src/utils/security/redaction.ts | 21 ++++++++++ 3 files changed, 65 insertions(+), 8 deletions(-) diff --git a/apps/dokploy/__test__/security/env-redaction.test.ts b/apps/dokploy/__test__/security/env-redaction.test.ts index eb0f832255..35807ed326 100644 --- a/apps/dokploy/__test__/security/env-redaction.test.ts +++ b/apps/dokploy/__test__/security/env-redaction.test.ts @@ -4,6 +4,7 @@ import { redactDatabaseServiceSecrets, redactDeployableServiceSecrets, redactSecretFields, + redactSensitiveText, } from "@dokploy/server/utils/security/redaction"; import { describe, expect, it } from "vitest"; @@ -85,4 +86,13 @@ describe("env secret redaction helpers", () => { name: "stack", }); }); + + it("redacts credentials embedded in custom git urls", () => { + expect( + redactSensitiveText("https://git-token@example.com/org/private.git"), + ).toBe(`https://${REDACTED_SECRET_VALUE}@example.com/org/private.git`); + expect(redactSensitiveText("https://example.com/org/public.git")).toBe( + "https://example.com/org/public.git", + ); + }); }); diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts index 66acbf8c8d..a514d9392e 100644 --- a/apps/dokploy/server/api/routers/compose.ts +++ b/apps/dokploy/server/api/routers/compose.ts @@ -50,6 +50,7 @@ import { preserveSecretPlaceholderFields, redactDeployableServiceSecrets, redactSecretFields, + redactSensitiveText, } from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; @@ -87,6 +88,33 @@ import { createTRPCRouter, protectedProcedure } from "../trpc"; import { audit } from "../utils/audit"; import { assertServiceEnvironmentReadAccess } 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, +) => + redactSecretFields( + redactCustomGitUrl(redactDeployableServiceSecrets(record)), + ["composeFile"], + ); + export const composeRouter = createTRPCRouter({ create: protectedProcedure .input(apiCreateCompose) @@ -136,7 +164,7 @@ export const composeRouter = createTRPCRouter({ resourceId: newService.composeId, resourceName: newService.appName, }); - return newService; + return redactComposeSecrets(newService); } catch (error) { throw error; } @@ -190,9 +218,7 @@ export const composeRouter = createTRPCRouter({ } return { - ...redactSecretFields(redactDeployableServiceSecrets(compose), [ - "composeFile", - ]), + ...redactComposeSecrets(compose), hasGitProviderAccess, unauthorizedProvider, }; @@ -234,7 +260,7 @@ export const composeRouter = createTRPCRouter({ resourceId: input.composeId, resourceName: updated?.name, }); - return updated; + return redactComposeSecrets(updated); }), saveEnvironment: protectedProcedure .input(apiSaveEnvironmentVariablesCompose) @@ -317,7 +343,7 @@ export const composeRouter = createTRPCRouter({ resourceId: composeResult.composeId, resourceName: composeResult.appName, }); - return composeResult; + return redactComposeSecrets(composeResult); }), cleanQueues: protectedProcedure .input(apiFindCompose) @@ -721,7 +747,7 @@ export const composeRouter = createTRPCRouter({ resourceId: compose.composeId, resourceName: compose.name, }); - return compose; + return redactComposeSecrets(compose); }), templates: protectedProcedure @@ -840,7 +866,7 @@ export const composeRouter = createTRPCRouter({ resourceId: input.composeId, resourceName: updatedCompose.name, }); - return updatedCompose; + return redactComposeSecrets(updatedCompose); }), processTemplate: protectedProcedure diff --git a/packages/server/src/utils/security/redaction.ts b/packages/server/src/utils/security/redaction.ts index b0821ea162..f8f8718c10 100644 --- a/packages/server/src/utils/security/redaction.ts +++ b/packages/server/src/utils/security/redaction.ts @@ -35,6 +35,27 @@ export const redactSecretFields = ( return redacted as T; }; +export function redactSensitiveText(value: string): string; +export function redactSensitiveText(value: null): null; +export function redactSensitiveText(value: undefined): undefined; +export function redactSensitiveText(value: string | null): string | null; +export function redactSensitiveText( + value: string | undefined, +): string | undefined; +export function redactSensitiveText( + value: string | null | undefined, +): string | null | undefined; +export function redactSensitiveText(value: string | null | undefined) { + if (typeof value !== "string" || value === "") { + return value; + } + + return value.replace( + /(\b[a-z][a-z0-9+.-]*:\/\/)([^@\s/?#]+)@/gi, + `$1${REDACTED_SECRET_VALUE}@`, + ); +} + const redactNestedServerSecrets = (server: T): T => { if (!server || typeof server !== "object") { return server; From a3a4e04472081c66fce53fe33b351a7be8bba2a2 Mon Sep 17 00:00:00 2001 From: agentHits <140916359+agentHits@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:51:25 +0300 Subject: [PATCH 5/5] fix: redact compose git provider secrets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Что: - Перенесен git provider redaction helper в feature-ветку. - Compose responses теперь закрывают вложенные github/gitlab/gitea/bitbucket provider credentials вместе с env, composeFile и customGitUrl. - Добавлен focused test на удаление provider credential fields. Зачем: - Закрыть reviewer blocker, где normal compose reads могли вернуть provider secrets из relation-backed responses. Риски: - Не выявлены. Проверки: - Команды и результаты: `corepack pnpm --dir apps/dokploy exec vitest --config __test__/vitest.config.ts run __test__/security/env-redaction.test.ts __test__/security/service-environment.test.ts __test__/git-provider/git-provider-access.test.ts __test__/env/upsert.test.ts __test__/env/application-env-upsert.test.ts` - passed (5 files / 41 tests); `corepack pnpm exec biome check <18 scoped files>` - passed; `git diff --check` - passed. - Ограничения: repo typecheck не запускался повторно; ранее блокировался baseline TypeScript 6/baseUrl config. What: - Ported the git provider redaction helper into the feature branch. - Compose responses now redact nested github/gitlab/gitea/bitbucket provider credentials along with env, composeFile, and customGitUrl. - Added focused coverage for removing provider credential fields. Why: - Close the reviewer blocker where normal compose reads could return provider secrets from relation-backed responses. Risks: - None identified. Checks: - Commands and results: `corepack pnpm --dir apps/dokploy exec vitest --config __test__/vitest.config.ts run __test__/security/env-redaction.test.ts __test__/security/service-environment.test.ts __test__/git-provider/git-provider-access.test.ts __test__/env/upsert.test.ts __test__/env/application-env-upsert.test.ts` - passed (5 files / 41 tests); `corepack pnpm exec biome check <18 scoped files>` - passed; `git diff --check` - passed. - Limitations: repo typecheck was not rerun; it was previously blocked by baseline TypeScript 6/baseUrl config. --- .../git-provider/git-provider-access.test.ts | 43 +++++++++- apps/dokploy/server/api/routers/compose.ts | 20 ++++- packages/server/src/services/git-provider.ts | 80 +++++++++++++++++-- 3 files changed, 135 insertions(+), 8 deletions(-) diff --git a/apps/dokploy/__test__/git-provider/git-provider-access.test.ts b/apps/dokploy/__test__/git-provider/git-provider-access.test.ts index 4ddf36244a..bc2a18c3f6 100644 --- a/apps/dokploy/__test__/git-provider/git-provider-access.test.ts +++ b/apps/dokploy/__test__/git-provider/git-provider-access.test.ts @@ -1,8 +1,9 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; import { canEditDeployGitSource, getAccessibleGitProviderIds, + redactGitProviderSecrets, } from "@dokploy/server/services/git-provider"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const mockDb = vi.hoisted(() => ({ query: { @@ -67,6 +68,46 @@ beforeEach(() => { mockHasValidLicense.mockResolvedValue(false); }); +describe("redactGitProviderSecrets", () => { + it("removes nested provider credential fields", () => { + const redacted = redactGitProviderSecrets({ + name: "compose", + github: { + githubClientId: "client-id", + githubClientSecret: "client-secret", + githubPrivateKey: "private-key", + githubWebhookSecret: "webhook-secret", + }, + gitlab: { + gitlabUrl: "https://gitlab.example.com", + secret: "secret", + accessToken: "access-token", + refreshToken: "refresh-token", + }, + gitea: { + giteaUrl: "https://gitea.example.com", + clientSecret: "client-secret", + accessToken: "access-token", + refreshToken: "refresh-token", + }, + bitbucket: { + bitbucketWorkspaceName: "workspace", + appPassword: "app-password", + apiToken: "api-token", + }, + }); + + expect(redacted.github).toEqual({ githubClientId: "client-id" }); + expect(redacted.gitlab).toEqual({ + gitlabUrl: "https://gitlab.example.com", + }); + expect(redacted.gitea).toEqual({ giteaUrl: "https://gitea.example.com" }); + expect(redacted.bitbucket).toEqual({ + bitbucketWorkspaceName: "workspace", + }); + }); +}); + describe("getAccessibleGitProviderIds", () => { describe("owner", () => { beforeEach(() => { diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts index a514d9392e..5746070ed3 100644 --- a/apps/dokploy/server/api/routers/compose.ts +++ b/apps/dokploy/server/api/routers/compose.ts @@ -33,7 +33,10 @@ import { updateDeploymentStatus, } from "@dokploy/server"; import { db } from "@dokploy/server/db"; -import { canEditDeployGitSource } from "@dokploy/server/services/git-provider"; +import { + canEditDeployGitSource, + redactGitProviderSecrets, +} from "@dokploy/server/services/git-provider"; import { addNewService, checkServiceAccess, @@ -111,7 +114,20 @@ const redactComposeSecrets = ( record: T, ) => redactSecretFields( - redactCustomGitUrl(redactDeployableServiceSecrets(record)), + redactCustomGitUrl( + redactDeployableServiceSecrets( + record + ? redactGitProviderSecrets( + record as T & { + bitbucket?: object | null; + gitea?: object | null; + github?: object | null; + gitlab?: object | null; + }, + ) + : record, + ), + ), ["composeFile"], ); diff --git a/packages/server/src/services/git-provider.ts b/packages/server/src/services/git-provider.ts index 942f94ed2c..f774f975f6 100644 --- a/packages/server/src/services/git-provider.ts +++ b/packages/server/src/services/git-provider.ts @@ -5,6 +5,77 @@ import { TRPCError } from "@trpc/server"; import { and, eq } from "drizzle-orm"; export type GitProvider = typeof gitProvider.$inferSelect; +type GitProviderSession = { + userId: string; + activeOrganizationId: string; +}; + +const githubSecretKeys = [ + "githubClientSecret", + "githubPrivateKey", + "githubWebhookSecret", +] as const; + +const gitlabSecretKeys = ["secret", "accessToken", "refreshToken"] as const; + +const giteaSecretKeys = [ + "clientSecret", + "accessToken", + "refreshToken", +] as const; + +const bitbucketSecretKeys = ["appPassword", "apiToken"] as const; + +const omitKeys = (value: T, keys: readonly string[]) => { + const redacted = { ...value } as Record; + for (const key of keys) { + delete redacted[key]; + } + return redacted as T; +}; + +const redactNullable = ( + value: T, + keys: readonly string[], +) => { + if (!value) { + return value; + } + return omitKeys(value, keys); +}; + +export const redactGithubProvider = ( + provider: T, +) => redactNullable(provider, githubSecretKeys); + +export const redactGitlabProvider = ( + provider: T, +) => redactNullable(provider, gitlabSecretKeys); + +export const redactGiteaProvider = ( + provider: T, +) => redactNullable(provider, giteaSecretKeys); + +export const redactBitbucketProvider = ( + provider: T, +) => redactNullable(provider, bitbucketSecretKeys); + +export const redactGitProviderSecrets = < + T extends { + github?: object | null; + gitlab?: object | null; + bitbucket?: object | null; + gitea?: object | null; + }, +>( + entity: T, +) => ({ + ...entity, + github: redactGithubProvider(entity.github), + gitlab: redactGitlabProvider(entity.gitlab), + bitbucket: redactBitbucketProvider(entity.bitbucket), + gitea: redactGiteaProvider(entity.gitea), +}); export const removeGitProvider = async (gitProviderId: string) => { const result = await db @@ -51,7 +122,7 @@ export const updateGitProvider = async ( // not to modify the git config of an existing deploy owned by someone else. export const canEditDeployGitSource = async ( gitProviderId: string, - session: { userId: string; activeOrganizationId: string }, + session: GitProviderSession, ): Promise => { const { userId, activeOrganizationId } = session; @@ -75,10 +146,9 @@ export const canEditDeployGitSource = async ( return provider.userId === userId || provider.sharedWithOrganization; }; -export const getAccessibleGitProviderIds = async (session: { - userId: string; - activeOrganizationId: string; -}): Promise> => { +export const getAccessibleGitProviderIds = async ( + session: GitProviderSession, +): Promise> => { const { userId, activeOrganizationId } = session; const allOrgProviders = await db.query.gitProvider.findMany({