From e76283bc0969f8d77563e22073c66c36c88e7b93 Mon Sep 17 00:00:00 2001 From: Narciso Date: Wed, 21 Oct 2026 22:18:25 -0400 Subject: [PATCH 1/4] fix: keep commands and secrets out of ExecError messages and inspect output --- .../process/exec-error-secrets.test.ts | 17 +++++++++ .../server/src/utils/process/ExecError.ts | 5 +++ .../server/src/utils/process/execAsync.ts | 38 ++++++++++++------- 3 files changed, 46 insertions(+), 14 deletions(-) create mode 100644 apps/dokploy/__test__/process/exec-error-secrets.test.ts diff --git a/apps/dokploy/__test__/process/exec-error-secrets.test.ts b/apps/dokploy/__test__/process/exec-error-secrets.test.ts new file mode 100644 index 000000000..64058ecaf --- /dev/null +++ b/apps/dokploy/__test__/process/exec-error-secrets.test.ts @@ -0,0 +1,17 @@ +import { inspect } from "node:util"; +import { execAsync, ExecError } from "@dokploy/server/utils/process/execAsync"; +import { describe, expect, it } from "vitest"; + +describe("execAsync failure", () => { + it("does not leak secrets embedded in the command", async () => { + const secret = "s3cr3t-value"; + const error = await execAsync(`printf %s '${secret}' >/dev/null; exit 3`) + .then(() => null) + .catch((e) => e); + + expect(error).toBeInstanceOf(ExecError); + expect(error.exitCode).toBe(3); + expect(error.message).not.toContain(secret); + expect(inspect(error)).not.toContain(secret); + }); +}); diff --git a/packages/server/src/utils/process/ExecError.ts b/packages/server/src/utils/process/ExecError.ts index 773968b5c..d76c17627 100644 --- a/packages/server/src/utils/process/ExecError.ts +++ b/packages/server/src/utils/process/ExecError.ts @@ -25,6 +25,11 @@ export class ExecError extends Error { this.originalError = details.originalError; this.serverId = details.serverId; + // Keep the command (may embed secrets) out of console/inspect output. + for (const key of ["command", "originalError"] as const) { + Object.defineProperty(this, key, { enumerable: false }); + } + // Maintains proper stack trace for where our error was thrown (only available on V8) if (Error.captureStackTrace) { Error.captureStackTrace(this, ExecError); diff --git a/packages/server/src/utils/process/execAsync.ts b/packages/server/src/utils/process/execAsync.ts index 37b0b7f91..bc700d0e0 100644 --- a/packages/server/src/utils/process/execAsync.ts +++ b/packages/server/src/utils/process/execAsync.ts @@ -23,6 +23,10 @@ export { ExecError } from "./ExecError"; const execAsyncBase = util.promisify(exec); +// Node embeds the full command in error.message; it may carry secrets. +const withoutCommand = (message: string, command: string) => + message.split(command).join("[command omitted]"); + export const execAsync = async ( command: string, options?: { cwd?: string; env?: NodeJS.ProcessEnv; shell?: string }, @@ -42,13 +46,16 @@ export const execAsync = async ( // @ts-expect-error const stderr = error.stderr?.toString() || ""; - throw new ExecError(`Command execution failed: ${error.message}`, { - command, - stdout, - stderr, - exitCode, - originalError: error, - }); + throw new ExecError( + `Command execution failed: ${withoutCommand(error.message, command)}`, + { + command, + stdout, + stderr, + exitCode, + originalError: error, + }, + ); } throw error; } @@ -71,13 +78,16 @@ export const execAsyncStream = ( const childProcess = exec(command, options, (error) => { if (error) { reject( - new ExecError(`Command execution failed: ${error.message}`, { - command, - stdout: stdoutComplete, - stderr: stderrComplete, - exitCode: error.code, - originalError: error, - }), + new ExecError( + `Command execution failed: ${withoutCommand(error.message, command)}`, + { + command, + stdout: stdoutComplete, + stderr: stderrComplete, + exitCode: error.code, + originalError: error, + }, + ), ); return; } From 99e4f1c8dc62510b8abd46bd7fd359d6ad61075e Mon Sep 17 00:00:00 2001 From: Narciso Date: Wed, 21 Oct 2026 22:18:25 -0400 Subject: [PATCH 2/4] fix(wss): bind container terminal to the service it was opened from --- .../__test__/wss/container-ownership.test.ts | 61 ++++++++++++++ apps/dokploy/server/wss/authorize.ts | 1 - .../dokploy/server/wss/container-ownership.ts | 82 +++++++++++++++++++ .../server/wss/docker-container-terminal.ts | 8 ++ 4 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 apps/dokploy/__test__/wss/container-ownership.test.ts create mode 100644 apps/dokploy/server/wss/container-ownership.ts diff --git a/apps/dokploy/__test__/wss/container-ownership.test.ts b/apps/dokploy/__test__/wss/container-ownership.test.ts new file mode 100644 index 000000000..68102e1f6 --- /dev/null +++ b/apps/dokploy/__test__/wss/container-ownership.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockFindApp = vi.hoisted(() => vi.fn()); +const mockExec = vi.hoisted(() => vi.fn()); +vi.mock("@dokploy/server/db", () => { + const none = { findFirst: async () => undefined }; + return { + db: { + query: { + applications: { findFirst: mockFindApp }, + compose: none, + postgres: none, + mysql: none, + mariadb: none, + mongo: none, + redis: none, + }, + }, + }; +}); +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: mockExec, + execAsyncRemote: mockExec, +})); + +import { containerBelongsToService } from "@/server/wss/container-ownership"; + +const labels = (l: Record) => ({ stdout: JSON.stringify(l) }); + +beforeEach(() => { + vi.clearAllMocks(); + mockFindApp.mockResolvedValue({ appName: "app-mine-abc123" }); +}); + +describe("containerBelongsToService", () => { + it("allows a container of the service", async () => { + mockExec.mockResolvedValue( + labels({ "com.docker.swarm.service.name": "app-mine-abc123" }), + ); + expect(await containerBelongsToService("svc", "c1")).toBe(true); + }); + + it("denies a container of another service", async () => { + mockExec.mockResolvedValue( + labels({ "com.docker.swarm.service.name": "dokploy" }), + ); + expect(await containerBelongsToService("svc", "c1")).toBe(false); + }); + + it("denies containers without labels or when docker fails", async () => { + mockExec.mockResolvedValue({ stdout: "null" }); + expect(await containerBelongsToService("svc", "c1")).toBe(false); + mockExec.mockRejectedValue(new Error("No such object")); + expect(await containerBelongsToService("svc", "c1")).toBe(false); + }); + + it("denies an unknown service", async () => { + mockFindApp.mockResolvedValue(undefined); + expect(await containerBelongsToService("nope", "c1")).toBe(false); + }); +}); diff --git a/apps/dokploy/server/wss/authorize.ts b/apps/dokploy/server/wss/authorize.ts index 740e0d9b5..b9a04311c 100644 --- a/apps/dokploy/server/wss/authorize.ts +++ b/apps/dokploy/server/wss/authorize.ts @@ -25,7 +25,6 @@ export const canAccessDockerOverWss = async ( serverId?: string | null, serviceId?: string | null, ): Promise => { - // return false; if (!user || !session?.activeOrganizationId) return false; const ctx = buildCtx(user, session.activeOrganizationId); diff --git a/apps/dokploy/server/wss/container-ownership.ts b/apps/dokploy/server/wss/container-ownership.ts new file mode 100644 index 000000000..9b43e3f66 --- /dev/null +++ b/apps/dokploy/server/wss/container-ownership.ts @@ -0,0 +1,82 @@ +import { db } from "@dokploy/server/db"; +import { + applications, + compose, + mariadb, + mongo, + mysql, + postgres, + redis, +} from "@dokploy/server/db/schema"; +import { + execAsync, + execAsyncRemote, +} from "@dokploy/server/utils/process/execAsync"; +import { eq } from "drizzle-orm"; +import { quote } from "shell-quote"; + +const OWNERSHIP_LABELS = [ + "com.docker.swarm.service.name", + "com.docker.compose.project", + "com.docker.stack.namespace", +]; + +const findServiceAppName = async (serviceId: string) => { + const [app, comp, pg, my, maria, mon, red] = await Promise.all([ + db.query.applications.findFirst({ + where: eq(applications.applicationId, serviceId), + columns: { appName: true }, + }), + db.query.compose.findFirst({ + where: eq(compose.composeId, serviceId), + columns: { appName: true }, + }), + db.query.postgres.findFirst({ + where: eq(postgres.postgresId, serviceId), + columns: { appName: true }, + }), + db.query.mysql.findFirst({ + where: eq(mysql.mysqlId, serviceId), + columns: { appName: true }, + }), + db.query.mariadb.findFirst({ + where: eq(mariadb.mariadbId, serviceId), + columns: { appName: true }, + }), + db.query.mongo.findFirst({ + where: eq(mongo.mongoId, serviceId), + columns: { appName: true }, + }), + db.query.redis.findFirst({ + where: eq(redis.redisId, serviceId), + columns: { appName: true }, + }), + ]); + return (app ?? comp ?? pg ?? my ?? maria ?? mon ?? red)?.appName; +}; + +// Service access alone must not allow `docker exec` into other tenants' containers. +export const containerBelongsToService = async ( + serviceId: string, + containerId: string, + serverId?: string | null, +): Promise => { + try { + const appName = await findServiceAppName(serviceId); + if (!appName) return false; + const command = quote([ + "docker", + "inspect", + "--format", + "{{json .Config.Labels}}", + containerId, + ]); + const { stdout } = serverId + ? await execAsyncRemote(serverId, command) + : await execAsync(command); + const labels = JSON.parse(stdout.trim()) ?? {}; + return OWNERSHIP_LABELS.some((label) => labels[label] === appName); + } catch { + return false; + } +}; diff --git a/apps/dokploy/server/wss/docker-container-terminal.ts b/apps/dokploy/server/wss/docker-container-terminal.ts index 45a3a4d34..5549808fc 100644 --- a/apps/dokploy/server/wss/docker-container-terminal.ts +++ b/apps/dokploy/server/wss/docker-container-terminal.ts @@ -4,6 +4,7 @@ import { spawn } from "node-pty"; import { Client } from "ssh2"; import { WebSocketServer } from "ws"; import { canAccessDockerOverWss } from "./authorize"; +import { containerBelongsToService } from "./container-ownership"; import { isValidContainerId, isValidShell, @@ -74,6 +75,13 @@ export const setupDockerContainerTerminalWebSocketServer = ( ws.close(4003, "Not authorized"); return; } + if ( + serviceId && + !(await containerBelongsToService(serviceId, containerId, serverId)) + ) { + ws.close(4003, "Not authorized"); + return; + } try { if (serverId) { const server = await findServerById(serverId); From 73c795e15780b30cf8f86cc71f79ad0fdcfb0b80 Mon Sep 17 00:00:00 2001 From: Narciso Date: Wed, 21 Oct 2026 22:26:02 -0400 Subject: [PATCH 3/4] fix(wss): resolve libsql services when binding the container terminal --- .../dokploy/__test__/wss/container-ownership.test.ts | 12 ++++++++++++ apps/dokploy/server/wss/container-ownership.ts | 9 +++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/dokploy/__test__/wss/container-ownership.test.ts b/apps/dokploy/__test__/wss/container-ownership.test.ts index 68102e1f6..19b60c076 100644 --- a/apps/dokploy/__test__/wss/container-ownership.test.ts +++ b/apps/dokploy/__test__/wss/container-ownership.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mockFindApp = vi.hoisted(() => vi.fn()); +const mockFindLibsql = vi.hoisted(() => vi.fn()); const mockExec = vi.hoisted(() => vi.fn()); vi.mock("@dokploy/server/db", () => { const none = { findFirst: async () => undefined }; @@ -14,6 +15,7 @@ vi.mock("@dokploy/server/db", () => { mariadb: none, mongo: none, redis: none, + libsql: { findFirst: mockFindLibsql }, }, }, }; @@ -30,6 +32,7 @@ const labels = (l: Record) => ({ stdout: JSON.stringify(l) }); beforeEach(() => { vi.clearAllMocks(); mockFindApp.mockResolvedValue({ appName: "app-mine-abc123" }); + mockFindLibsql.mockResolvedValue(undefined); }); describe("containerBelongsToService", () => { @@ -54,6 +57,15 @@ describe("containerBelongsToService", () => { expect(await containerBelongsToService("svc", "c1")).toBe(false); }); + it("resolves libsql services", async () => { + mockFindApp.mockResolvedValue(undefined); + mockFindLibsql.mockResolvedValue({ appName: "libsql-abc" }); + mockExec.mockResolvedValue( + labels({ "com.docker.swarm.service.name": "libsql-abc" }), + ); + expect(await containerBelongsToService("svc", "c1")).toBe(true); + }); + it("denies an unknown service", async () => { mockFindApp.mockResolvedValue(undefined); expect(await containerBelongsToService("nope", "c1")).toBe(false); diff --git a/apps/dokploy/server/wss/container-ownership.ts b/apps/dokploy/server/wss/container-ownership.ts index 9b43e3f66..3bc16bb41 100644 --- a/apps/dokploy/server/wss/container-ownership.ts +++ b/apps/dokploy/server/wss/container-ownership.ts @@ -2,6 +2,7 @@ import { db } from "@dokploy/server/db"; import { applications, compose, + libsql, mariadb, mongo, mysql, @@ -22,7 +23,7 @@ const OWNERSHIP_LABELS = [ ]; const findServiceAppName = async (serviceId: string) => { - const [app, comp, pg, my, maria, mon, red] = await Promise.all([ + const [app, comp, pg, my, maria, mon, red, lib] = await Promise.all([ db.query.applications.findFirst({ where: eq(applications.applicationId, serviceId), columns: { appName: true }, @@ -51,8 +52,12 @@ const findServiceAppName = async (serviceId: string) => { where: eq(redis.redisId, serviceId), columns: { appName: true }, }), + db.query.libsql.findFirst({ + where: eq(libsql.libsqlId, serviceId), + columns: { appName: true }, + }), ]); - return (app ?? comp ?? pg ?? my ?? maria ?? mon ?? red)?.appName; + return (app ?? comp ?? pg ?? my ?? maria ?? mon ?? red ?? lib)?.appName; }; // Service access alone must not allow `docker exec` into other tenants' containers. From 47b0d6563a1444ad7890311933f6e3a4946955b1 Mon Sep 17 00:00:00 2001 From: Narciso Date: Wed, 21 Oct 2026 22:41:15 -0400 Subject: [PATCH 4/4] refactor(wss): look up service app names from a table list --- .../__test__/wss/container-ownership.test.ts | 45 ++++++++-------- .../dokploy/server/wss/container-ownership.ts | 54 +++++++------------ 2 files changed, 40 insertions(+), 59 deletions(-) diff --git a/apps/dokploy/__test__/wss/container-ownership.test.ts b/apps/dokploy/__test__/wss/container-ownership.test.ts index 19b60c076..1dce706fc 100644 --- a/apps/dokploy/__test__/wss/container-ownership.test.ts +++ b/apps/dokploy/__test__/wss/container-ownership.test.ts @@ -1,38 +1,35 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const mockFindApp = vi.hoisted(() => vi.fn()); -const mockFindLibsql = vi.hoisted(() => vi.fn()); +const mockRows = vi.hoisted(() => new Map()); const mockExec = vi.hoisted(() => vi.fn()); -vi.mock("@dokploy/server/db", () => { - const none = { findFirst: async () => undefined }; - return { - db: { - query: { - applications: { findFirst: mockFindApp }, - compose: none, - postgres: none, - mysql: none, - mariadb: none, - mongo: none, - redis: none, - libsql: { findFirst: mockFindLibsql }, - }, - }, - }; -}); +vi.mock("@dokploy/server/db", () => ({ + db: { + select: () => ({ + from: (table: unknown) => ({ + where: () => ({ + limit: async () => { + const row = mockRows.get(table); + return row ? [row] : []; + }, + }), + }), + }), + }, +})); vi.mock("@dokploy/server/utils/process/execAsync", () => ({ execAsync: mockExec, execAsyncRemote: mockExec, })); +import { applications, libsql } from "@dokploy/server/db/schema"; import { containerBelongsToService } from "@/server/wss/container-ownership"; const labels = (l: Record) => ({ stdout: JSON.stringify(l) }); beforeEach(() => { vi.clearAllMocks(); - mockFindApp.mockResolvedValue({ appName: "app-mine-abc123" }); - mockFindLibsql.mockResolvedValue(undefined); + mockRows.clear(); + mockRows.set(applications, { appName: "app-mine-abc123" }); }); describe("containerBelongsToService", () => { @@ -58,8 +55,8 @@ describe("containerBelongsToService", () => { }); it("resolves libsql services", async () => { - mockFindApp.mockResolvedValue(undefined); - mockFindLibsql.mockResolvedValue({ appName: "libsql-abc" }); + mockRows.clear(); + mockRows.set(libsql, { appName: "libsql-abc" }); mockExec.mockResolvedValue( labels({ "com.docker.swarm.service.name": "libsql-abc" }), ); @@ -67,7 +64,7 @@ describe("containerBelongsToService", () => { }); it("denies an unknown service", async () => { - mockFindApp.mockResolvedValue(undefined); + mockRows.clear(); expect(await containerBelongsToService("nope", "c1")).toBe(false); }); }); diff --git a/apps/dokploy/server/wss/container-ownership.ts b/apps/dokploy/server/wss/container-ownership.ts index 3bc16bb41..0c536a5e3 100644 --- a/apps/dokploy/server/wss/container-ownership.ts +++ b/apps/dokploy/server/wss/container-ownership.ts @@ -22,42 +22,26 @@ const OWNERSHIP_LABELS = [ "com.docker.stack.namespace", ]; +const SERVICE_TABLES = [ + { table: applications, id: applications.applicationId }, + { table: compose, id: compose.composeId }, + { table: postgres, id: postgres.postgresId }, + { table: mysql, id: mysql.mysqlId }, + { table: mariadb, id: mariadb.mariadbId }, + { table: mongo, id: mongo.mongoId }, + { table: redis, id: redis.redisId }, + { table: libsql, id: libsql.libsqlId }, +]; + const findServiceAppName = async (serviceId: string) => { - const [app, comp, pg, my, maria, mon, red, lib] = await Promise.all([ - db.query.applications.findFirst({ - where: eq(applications.applicationId, serviceId), - columns: { appName: true }, - }), - db.query.compose.findFirst({ - where: eq(compose.composeId, serviceId), - columns: { appName: true }, - }), - db.query.postgres.findFirst({ - where: eq(postgres.postgresId, serviceId), - columns: { appName: true }, - }), - db.query.mysql.findFirst({ - where: eq(mysql.mysqlId, serviceId), - columns: { appName: true }, - }), - db.query.mariadb.findFirst({ - where: eq(mariadb.mariadbId, serviceId), - columns: { appName: true }, - }), - db.query.mongo.findFirst({ - where: eq(mongo.mongoId, serviceId), - columns: { appName: true }, - }), - db.query.redis.findFirst({ - where: eq(redis.redisId, serviceId), - columns: { appName: true }, - }), - db.query.libsql.findFirst({ - where: eq(libsql.libsqlId, serviceId), - columns: { appName: true }, - }), - ]); - return (app ?? comp ?? pg ?? my ?? maria ?? mon ?? red ?? lib)?.appName; + for (const { table, id } of SERVICE_TABLES) { + const [row] = await db + .select({ appName: table.appName }) + .from(table) + .where(eq(id, serviceId)) + .limit(1); + if (row) return row.appName; + } }; // Service access alone must not allow `docker exec` into other tenants' containers.