From 99e4f1c8dc62510b8abd46bd7fd359d6ad61075e Mon Sep 17 00:00:00 2001 From: Narciso Date: Wed, 21 Oct 2026 22:18:25 -0400 Subject: [PATCH] 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);