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";