From 479d8518297ccaaa3282026e39b5241b1bb14b6d Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Mon, 20 Jul 2026 00:14:39 -0600 Subject: [PATCH] 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} >