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
This commit is contained in:
Mauricio Siu
2026-07-19 23:49:02 -06:00
parent 637715ac66
commit 68f5afae42
6 changed files with 173 additions and 0 deletions

View File

@@ -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();
});
});

View File

@@ -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<boolean> => {
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<boolean> => {
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;
}
};

View File

@@ -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(() => {

View File

@@ -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);

View File

@@ -9,6 +9,7 @@ import {
validateRequest,
} from "@dokploy/server";
import { WebSocketServer } from "ws";
import { canAccessDockerOverWss } from "./authorize";
export const setupDockerStatsMonitoringSocketServer = (
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
@@ -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

View File

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