From 68f5afae42fca353dcb3d3bc6219ffe9e168cb91 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sun, 19 Jul 2026 23:49:02 -0600 Subject: [PATCH 1/9] fix(security): authorize docker/terminal WebSocket handlers The /terminal, /docker-container-terminal, /docker-container-logs and /docker-stats WebSocket handlers only checked session + organization, so any authenticated member could open a root shell / read logs of any container, and the local (serverId=local) branch reached a root SSH shell on the control-plane host. Adds an authorization layer (server/wss/authorize.ts): - container ops (terminal/logs/stats): require the docker permission (owner/admin or a member granted canAccessToDocker); for a remote server, require the server be accessible to the caller. - host/server terminal: the local host root terminal is restricted to owner/admin; a remote server terminal is gated on server access. Closes GHSA-c68r-7wg9-p7v2, GHSA-899j-cjwp-v4gw, GHSA-7r6p-v9gw-pwc8, GHSA-qf9j-c9p4-r4xp --- apps/dokploy/__test__/wss/authorize.test.ts | 80 +++++++++++++++++++ apps/dokploy/server/wss/authorize.ts | 69 ++++++++++++++++ .../server/wss/docker-container-logs.ts | 6 ++ .../server/wss/docker-container-terminal.ts | 6 ++ apps/dokploy/server/wss/docker-stats.ts | 6 ++ apps/dokploy/server/wss/terminal.ts | 6 ++ 6 files changed, 173 insertions(+) create mode 100644 apps/dokploy/__test__/wss/authorize.test.ts create mode 100644 apps/dokploy/server/wss/authorize.ts diff --git a/apps/dokploy/__test__/wss/authorize.test.ts b/apps/dokploy/__test__/wss/authorize.test.ts new file mode 100644 index 000000000..45379f684 --- /dev/null +++ b/apps/dokploy/__test__/wss/authorize.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock the permission + server helpers the wss authorizer composes. +const mockHasPermission = vi.hoisted(() => vi.fn()); +const mockFindMember = vi.hoisted(() => vi.fn()); +vi.mock("@dokploy/server/services/permission", () => ({ + hasPermission: mockHasPermission, + findMemberByUserId: mockFindMember, +})); + +const mockGetAccessibleServerIds = vi.hoisted(() => vi.fn()); +vi.mock("@dokploy/server", () => ({ + getAccessibleServerIds: mockGetAccessibleServerIds, +})); + +import { + canAccessDockerOverWss, + canAccessTerminalOverWss, +} from "@/server/wss/authorize"; + +const USER = { id: "user-1" }; +const SESSION = { activeOrganizationId: "org-1" }; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("canAccessDockerOverWss", () => { + it("denies when there is no user or session", async () => { + expect(await canAccessDockerOverWss(null, SESSION)).toBe(false); + expect(await canAccessDockerOverWss(USER, null)).toBe(false); + }); + + it("denies a member without docker permission", async () => { + mockHasPermission.mockResolvedValue(false); + expect(await canAccessDockerOverWss(USER, SESSION)).toBe(false); + }); + + it("allows when the caller has docker permission (no server)", async () => { + mockHasPermission.mockResolvedValue(true); + expect(await canAccessDockerOverWss(USER, SESSION)).toBe(true); + }); + + it("denies a remote server the caller cannot access, even with docker permission", async () => { + mockHasPermission.mockResolvedValue(true); + mockGetAccessibleServerIds.mockResolvedValue(new Set(["other-server"])); + expect(await canAccessDockerOverWss(USER, SESSION, "srv-1")).toBe(false); + }); + + it("allows a remote server the caller can access", async () => { + mockHasPermission.mockResolvedValue(true); + mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"])); + expect(await canAccessDockerOverWss(USER, SESSION, "srv-1")).toBe(true); + }); +}); + +describe("canAccessTerminalOverWss", () => { + it("denies the local host terminal to a plain member", async () => { + mockFindMember.mockResolvedValue({ role: "member" }); + expect(await canAccessTerminalOverWss(USER, SESSION, "local")).toBe(false); + }); + + it("allows the local host terminal to an owner", async () => { + mockFindMember.mockResolvedValue({ role: "owner" }); + expect(await canAccessTerminalOverWss(USER, SESSION, "local")).toBe(true); + }); + + it("allows the local host terminal to an admin", async () => { + mockFindMember.mockResolvedValue({ role: "admin" }); + expect(await canAccessTerminalOverWss(USER, SESSION, "local")).toBe(true); + }); + + it("gates a remote server terminal on server access", async () => { + mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"])); + expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(true); + expect(await canAccessTerminalOverWss(USER, SESSION, "srv-2")).toBe(false); + // role lookup must not be needed for the remote path + expect(mockFindMember).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/dokploy/server/wss/authorize.ts b/apps/dokploy/server/wss/authorize.ts new file mode 100644 index 000000000..083048777 --- /dev/null +++ b/apps/dokploy/server/wss/authorize.ts @@ -0,0 +1,69 @@ +import { getAccessibleServerIds } from "@dokploy/server"; +import { + findMemberByUserId, + hasPermission, +} from "@dokploy/server/services/permission"; + +type WssUser = { id: string } | null | undefined; +type WssSession = { activeOrganizationId?: string | null } | null | undefined; + +const buildCtx = (user: { id: string }, activeOrganizationId: string) => ({ + user: { id: user.id }, + session: { activeOrganizationId }, +}); + +// Authorizes docker/container operations opened over a WebSocket (container +// terminal, container logs, container stats). Requires the docker permission +// (owner/admin, or a member explicitly granted canAccessToDocker) and, for a +// remote server, that the server is accessible to the caller. Previously these +// handlers only checked session + organization, so any member could reach a +// root shell / logs of any container. +export const canAccessDockerOverWss = async ( + user: WssUser, + session: WssSession, + serverId?: string | null, +): Promise => { + if (!user || !session?.activeOrganizationId) return false; + + const ctx = buildCtx(user, session.activeOrganizationId); + if (!(await hasPermission(ctx, { docker: ["read"] }))) return false; + + if (serverId && serverId !== "local") { + const accessible = await getAccessibleServerIds({ + userId: user.id, + activeOrganizationId: session.activeOrganizationId, + }); + if (!accessible.has(serverId)) return false; + } + + return true; +}; + +// Authorizes the host/server SSH terminal opened over a WebSocket. The local +// host terminal is a root shell on the control-plane host, so it is restricted +// to owner/admin. A remote server terminal is gated on server access. +export const canAccessTerminalOverWss = async ( + user: WssUser, + session: WssSession, + serverId?: string | null, +): Promise => { + if (!user || !session?.activeOrganizationId) return false; + + if (serverId && serverId !== "local") { + const accessible = await getAccessibleServerIds({ + userId: user.id, + activeOrganizationId: session.activeOrganizationId, + }); + return accessible.has(serverId); + } + + try { + const member = await findMemberByUserId( + user.id, + session.activeOrganizationId, + ); + return member?.role === "owner" || member?.role === "admin"; + } catch { + return false; + } +}; diff --git a/apps/dokploy/server/wss/docker-container-logs.ts b/apps/dokploy/server/wss/docker-container-logs.ts index ed4541558..d244ab39f 100644 --- a/apps/dokploy/server/wss/docker-container-logs.ts +++ b/apps/dokploy/server/wss/docker-container-logs.ts @@ -3,6 +3,7 @@ import { findServerById, IS_CLOUD, validateRequest } from "@dokploy/server"; import { spawn } from "node-pty"; import { Client } from "ssh2"; import { WebSocketServer } from "ws"; +import { canAccessDockerOverWss } from "./authorize"; import { getShell, isValidContainerId, @@ -74,6 +75,11 @@ export const setupDockerContainerLogsWebSocketServer = ( return; } + if (!(await canAccessDockerOverWss(user, session, serverId))) { + ws.close(4003, "Not authorized"); + return; + } + // Set up keep-alive ping mechanism to prevent timeout // Send ping every 45 seconds to keep connection alive const pingInterval = setInterval(() => { diff --git a/apps/dokploy/server/wss/docker-container-terminal.ts b/apps/dokploy/server/wss/docker-container-terminal.ts index e752c0651..885c0d8f7 100644 --- a/apps/dokploy/server/wss/docker-container-terminal.ts +++ b/apps/dokploy/server/wss/docker-container-terminal.ts @@ -3,6 +3,7 @@ import { findServerById, IS_CLOUD, validateRequest } from "@dokploy/server"; import { spawn } from "node-pty"; import { Client } from "ssh2"; import { WebSocketServer } from "ws"; +import { canAccessDockerOverWss } from "./authorize"; import { isValidContainerId, isValidShell } from "./utils"; export const setupDockerContainerTerminalWebSocketServer = ( @@ -58,6 +59,11 @@ export const setupDockerContainerTerminalWebSocketServer = ( ws.close(); return; } + + if (!(await canAccessDockerOverWss(user, session, serverId))) { + ws.close(4003, "Not authorized"); + return; + } try { if (serverId) { const server = await findServerById(serverId); diff --git a/apps/dokploy/server/wss/docker-stats.ts b/apps/dokploy/server/wss/docker-stats.ts index b5f2439bf..a22c490a4 100644 --- a/apps/dokploy/server/wss/docker-stats.ts +++ b/apps/dokploy/server/wss/docker-stats.ts @@ -9,6 +9,7 @@ import { validateRequest, } from "@dokploy/server"; import { WebSocketServer } from "ws"; +import { canAccessDockerOverWss } from "./authorize"; export const setupDockerStatsMonitoringSocketServer = ( server: http.Server, @@ -55,6 +56,11 @@ export const setupDockerStatsMonitoringSocketServer = ( ws.close(); return; } + + if (!(await canAccessDockerOverWss(user, session))) { + ws.close(4003, "Not authorized"); + return; + } const intervalId = setInterval(async () => { try { // Special case: when monitoring "dokploy", get host system stats instead of container stats diff --git a/apps/dokploy/server/wss/terminal.ts b/apps/dokploy/server/wss/terminal.ts index 4825f7301..90c36855e 100644 --- a/apps/dokploy/server/wss/terminal.ts +++ b/apps/dokploy/server/wss/terminal.ts @@ -9,6 +9,7 @@ import { publicIpv4, publicIpv6 } from "public-ip"; import { Client, type ConnectConfig } from "ssh2"; import { WebSocketServer } from "ws"; import { getDockerHost } from "../utils/docker"; +import { canAccessTerminalOverWss } from "./authorize"; import { setupLocalServerSSHKey } from "./utils"; const COMMAND_TO_ALLOW_LOCAL_ACCESS = ` @@ -93,6 +94,11 @@ export const setupTerminalWebSocketServer = ( return; } + if (!(await canAccessTerminalOverWss(user, session, serverId))) { + ws.close(4003, "Not authorized"); + return; + } + let connectionDetails: ConnectConfig = {}; const isLocalServer = serverId === "local"; From 479d8518297ccaaa3282026e39b5241b1bb14b6d Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Mon, 20 Jul 2026 00:14:39 -0600 Subject: [PATCH 2/9] feat(security): enforce service-level access on docker WebSocket handlers Completes GHSA-qf9j service-level dimension: when a container terminal/logs/stats is opened from a service page, the frontend now passes serviceId, and the wss authorizer calls checkServiceAccess so a member restricted to specific services cannot reach another service's containers. Generic Docker-overview opens have no serviceId and keep the docker-permission + server-access gate. Verified against the local WebSocket: a member (docker:read=false) is rejected with close code 4003; owner is allowed. Closes GHSA-qf9j-c9p4-r4xp --- apps/dokploy/__test__/wss/authorize.test.ts | 18 ++++++++++++++++++ .../dashboard/compose/general/actions.tsx | 1 + .../docker/terminal/docker-terminal.tsx | 4 +++- .../libsql/general/show-general-libsql.tsx | 1 + .../mariadb/general/show-general-mariadb.tsx | 1 + .../mongo/general/show-general-mongo.tsx | 1 + .../mysql/general/show-general-mysql.tsx | 1 + .../postgres/general/show-general-postgres.tsx | 1 + .../redis/general/show-general-redis.tsx | 1 + .../web-server/docker-terminal-modal.tsx | 3 +++ apps/dokploy/server/wss/authorize.ts | 14 ++++++++++++++ .../server/wss/docker-container-logs.ts | 3 ++- .../server/wss/docker-container-terminal.ts | 3 ++- apps/dokploy/server/wss/docker-stats.ts | 3 ++- 14 files changed, 51 insertions(+), 4 deletions(-) diff --git a/apps/dokploy/__test__/wss/authorize.test.ts b/apps/dokploy/__test__/wss/authorize.test.ts index 45379f684..cb83d1e1e 100644 --- a/apps/dokploy/__test__/wss/authorize.test.ts +++ b/apps/dokploy/__test__/wss/authorize.test.ts @@ -3,9 +3,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; // Mock the permission + server helpers the wss authorizer composes. const mockHasPermission = vi.hoisted(() => vi.fn()); const mockFindMember = vi.hoisted(() => vi.fn()); +const mockCheckServiceAccess = vi.hoisted(() => vi.fn()); vi.mock("@dokploy/server/services/permission", () => ({ hasPermission: mockHasPermission, findMemberByUserId: mockFindMember, + checkServiceAccess: mockCheckServiceAccess, })); const mockGetAccessibleServerIds = vi.hoisted(() => vi.fn()); @@ -52,6 +54,22 @@ describe("canAccessDockerOverWss", () => { mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"])); expect(await canAccessDockerOverWss(USER, SESSION, "srv-1")).toBe(true); }); + + it("denies when the container belongs to a service the caller cannot access", async () => { + mockHasPermission.mockResolvedValue(true); + mockCheckServiceAccess.mockRejectedValue(new Error("no access")); + expect(await canAccessDockerOverWss(USER, SESSION, null, "svc-1")).toBe( + false, + ); + }); + + it("allows when the caller has access to the container's service", async () => { + mockHasPermission.mockResolvedValue(true); + mockCheckServiceAccess.mockResolvedValue(undefined); + expect(await canAccessDockerOverWss(USER, SESSION, null, "svc-1")).toBe( + true, + ); + }); }); describe("canAccessTerminalOverWss", () => { diff --git a/apps/dokploy/components/dashboard/compose/general/actions.tsx b/apps/dokploy/components/dashboard/compose/general/actions.tsx index c2b984b39..0ac6d4c68 100644 --- a/apps/dokploy/components/dashboard/compose/general/actions.tsx +++ b/apps/dokploy/components/dashboard/compose/general/actions.tsx @@ -206,6 +206,7 @@ export const ComposeActions = ({ composeId }: Props) => { appName={data?.appName || ""} serverId={data?.serverId || ""} appType={data?.composeType || "docker-compose"} + serviceId={data?.composeId} >