From 018e2b153e564cf9af163d47ce4a1ee409dfb848 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Fri, 24 Apr 2026 12:44:42 -0600 Subject: [PATCH 1/2] fix: add cross-org ownership checks to cluster, deployment, backup, and WebSocket endpoints Prevents owner/admin users of one organization from accessing servers, destinations, and Docker Swarm join tokens belonging to other organizations by validating organizationId on all endpoints that accept serverId or destinationId as direct input. - cluster: validate serverId org on getNodes, addWorker, addManager, removeWorker - deployment: validate serverId org on allByServer - backup: validate destinationId + serverId org on listBackupFiles - volume-backups: validate destinationId + serverId org on restoreVolumeBackupWithLogs - wss: validate server org on docker-container-logs, docker-container-terminal, listen-deployment, and terminal WebSocket handlers - auth: fix TypeScript type for API key metadata parsing --- apps/dokploy/server/api/routers/backup.ts | 19 +++++++++- apps/dokploy/server/api/routers/cluster.ts | 36 +++++++++++++++++-- apps/dokploy/server/api/routers/deployment.ts | 10 +++++- .../server/api/routers/volume-backups.ts | 20 ++++++++++- .../server/wss/docker-container-logs.ts | 5 +++ .../server/wss/docker-container-terminal.ts | 6 ++++ apps/dokploy/server/wss/listen-deployment.ts | 5 +++ apps/dokploy/server/wss/terminal.ts | 5 +++ packages/server/src/lib/auth.ts | 6 ++-- 9 files changed, 104 insertions(+), 8 deletions(-) diff --git a/apps/dokploy/server/api/routers/backup.ts b/apps/dokploy/server/api/routers/backup.ts index c3633b135..75bb60f2c 100644 --- a/apps/dokploy/server/api/routers/backup.ts +++ b/apps/dokploy/server/api/routers/backup.ts @@ -458,9 +458,26 @@ export const backupRouter = createTRPCRouter({ serverId: z.string().optional(), }), ) - .query(async ({ input }) => { + .query(async ({ input, ctx }) => { try { const destination = await findDestinationById(input.destinationId); + if (destination.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You don't have access to this destination.", + }); + } + if (input.serverId) { + const targetServer = await findServerById(input.serverId); + if ( + targetServer.organizationId !== ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You don't have access to this server.", + }); + } + } const rcloneFlags = getS3Credentials(destination); const bucketPath = `:s3:${destination.bucket}`; diff --git a/apps/dokploy/server/api/routers/cluster.ts b/apps/dokploy/server/api/routers/cluster.ts index afd8a0e92..dad67a3d0 100644 --- a/apps/dokploy/server/api/routers/cluster.ts +++ b/apps/dokploy/server/api/routers/cluster.ts @@ -11,6 +11,20 @@ import { audit } from "@/server/api/utils/audit"; import { getLocalServerIp } from "@/server/wss/terminal"; import { createTRPCRouter, withPermission } from "../trpc"; +const assertServerBelongsToOrg = async ( + serverId: string | undefined, + activeOrganizationId: string, +) => { + if (!serverId) return; + const targetServer = await findServerById(serverId); + if (targetServer.organizationId !== activeOrganizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You don't have access to this server.", + }); + } +}; + export const clusterRouter = createTRPCRouter({ getNodes: withPermission("server", "read") .input( @@ -18,7 +32,11 @@ export const clusterRouter = createTRPCRouter({ serverId: z.string().optional(), }), ) - .query(async ({ input }) => { + .query(async ({ input, ctx }) => { + await assertServerBelongsToOrg( + input.serverId, + ctx.session.activeOrganizationId, + ); const docker = await getRemoteDocker(input.serverId); const workers: DockerNode[] = await docker.listNodes(); return workers; @@ -32,6 +50,10 @@ export const clusterRouter = createTRPCRouter({ }), ) .mutation(async ({ input, ctx }) => { + await assertServerBelongsToOrg( + input.serverId, + ctx.session.activeOrganizationId, + ); try { const drainCommand = `docker node update --availability drain ${input.nodeId}`; const removeCommand = `docker node rm ${input.nodeId} --force`; @@ -65,7 +87,11 @@ export const clusterRouter = createTRPCRouter({ serverId: z.string().optional(), }), ) - .query(async ({ input }) => { + .query(async ({ input, ctx }) => { + await assertServerBelongsToOrg( + input.serverId, + ctx.session.activeOrganizationId, + ); const docker = await getRemoteDocker(input.serverId); const result = await docker.swarmInspect(); const docker_version = await docker.version(); @@ -88,7 +114,11 @@ export const clusterRouter = createTRPCRouter({ serverId: z.string().optional(), }), ) - .query(async ({ input }) => { + .query(async ({ input, ctx }) => { + await assertServerBelongsToOrg( + input.serverId, + ctx.session.activeOrganizationId, + ); const docker = await getRemoteDocker(input.serverId); const result = await docker.swarmInspect(); const docker_version = await docker.version(); diff --git a/apps/dokploy/server/api/routers/deployment.ts b/apps/dokploy/server/api/routers/deployment.ts index 03cd3c935..6f3b1d1ae 100644 --- a/apps/dokploy/server/api/routers/deployment.ts +++ b/apps/dokploy/server/api/routers/deployment.ts @@ -16,6 +16,7 @@ import { checkServicePermissionAndAccess, findMemberByUserId, } from "@dokploy/server/services/permission"; +import { findServerById } from "@dokploy/server/services/server"; import { TRPCError } from "@trpc/server"; import { desc, eq } from "drizzle-orm"; import { z } from "zod"; @@ -52,7 +53,14 @@ export const deploymentRouter = createTRPCRouter({ }), allByServer: withPermission("deployment", "read") .input(apiFindAllByServer) - .query(async ({ input }) => { + .query(async ({ input, ctx }) => { + const targetServer = await findServerById(input.serverId); + if (targetServer.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You don't have access to this server.", + }); + } return await findAllDeploymentsByServerId(input.serverId); }), allCentralized: withPermission("deployment", "read").query( diff --git a/apps/dokploy/server/api/routers/volume-backups.ts b/apps/dokploy/server/api/routers/volume-backups.ts index 5b50219d2..1f589d1e3 100644 --- a/apps/dokploy/server/api/routers/volume-backups.ts +++ b/apps/dokploy/server/api/routers/volume-backups.ts @@ -15,7 +15,9 @@ import { updateVolumeBackupSchema, volumeBackups, } from "@dokploy/server/db/schema"; +import { findDestinationById } from "@dokploy/server/services/destination"; import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission"; +import { findServerById } from "@dokploy/server/services/server"; import { execAsyncRemote, execAsyncStream, @@ -265,7 +267,23 @@ export const volumeBackupsRouter = createTRPCRouter({ serverId: z.string().optional(), }), ) - .subscription(async ({ input }) => { + .subscription(async ({ input, ctx }) => { + const destination = await findDestinationById(input.destinationId); + if (destination.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You don't have access to this destination.", + }); + } + if (input.serverId) { + const targetServer = await findServerById(input.serverId); + if (targetServer.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You don't have access to this server.", + }); + } + } return observable((emit) => { const runRestore = async () => { try { diff --git a/apps/dokploy/server/wss/docker-container-logs.ts b/apps/dokploy/server/wss/docker-container-logs.ts index 159bedaae..ed4541558 100644 --- a/apps/dokploy/server/wss/docker-container-logs.ts +++ b/apps/dokploy/server/wss/docker-container-logs.ts @@ -85,6 +85,11 @@ export const setupDockerContainerLogsWebSocketServer = ( if (serverId) { const server = await findServerById(serverId); + if (server.organizationId !== session.activeOrganizationId) { + ws.close(); + return; + } + if (!server.sshKeyId) return; const client = new Client(); client diff --git a/apps/dokploy/server/wss/docker-container-terminal.ts b/apps/dokploy/server/wss/docker-container-terminal.ts index a2c242d95..e752c0651 100644 --- a/apps/dokploy/server/wss/docker-container-terminal.ts +++ b/apps/dokploy/server/wss/docker-container-terminal.ts @@ -61,6 +61,12 @@ export const setupDockerContainerTerminalWebSocketServer = ( try { if (serverId) { const server = await findServerById(serverId); + + if (server.organizationId !== session.activeOrganizationId) { + ws.close(); + return; + } + if (!server.sshKeyId) throw new Error("No SSH key available for this server"); diff --git a/apps/dokploy/server/wss/listen-deployment.ts b/apps/dokploy/server/wss/listen-deployment.ts index c39fa70b7..cd9eefed6 100644 --- a/apps/dokploy/server/wss/listen-deployment.ts +++ b/apps/dokploy/server/wss/listen-deployment.ts @@ -57,6 +57,11 @@ export const setupDeploymentLogsWebSocketServer = ( if (serverId) { const server = await findServerById(serverId); + if (server.organizationId !== session.activeOrganizationId) { + ws.close(); + return; + } + if (!server.sshKeyId) { ws.close(); return; diff --git a/apps/dokploy/server/wss/terminal.ts b/apps/dokploy/server/wss/terminal.ts index 00b0e2c2c..4825f7301 100644 --- a/apps/dokploy/server/wss/terminal.ts +++ b/apps/dokploy/server/wss/terminal.ts @@ -154,6 +154,11 @@ export const setupTerminalWebSocketServer = ( return; } + if (server.organizationId !== session.activeOrganizationId) { + ws.close(); + return; + } + const { ipAddress: host, port, username, sshKey, sshKeyId } = server; if (!sshKeyId) { diff --git a/packages/server/src/lib/auth.ts b/packages/server/src/lib/auth.ts index 65dd1b01d..afbc57881 100644 --- a/packages/server/src/lib/auth.ts +++ b/packages/server/src/lib/auth.ts @@ -481,8 +481,10 @@ export const validateRequest = async (request: IncomingMessage) => { }; } - const organizationId = JSON.parse( - apiKeyRecord.metadata || "{}", + const organizationId = ( + JSON.parse(apiKeyRecord.metadata || "{}") as { + organizationId?: string; + } ).organizationId; if (!organizationId) { From 232ccc913967e28d58635a989a80cd77733d9f96 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Fri, 24 Apr 2026 12:47:51 -0600 Subject: [PATCH 2/2] feat: add organization-level authorization checks to WebSocket servers - Implemented checks in the WebSocket server setups for Docker container logs, terminal, and deployment logs to ensure users can only access resources associated with their active organization. - Enhanced security by closing WebSocket connections if the organization ID does not match the session's active organization ID. --- apps/dokploy/server/api/routers/cluster.ts | 66 ++++++++++++---------- 1 file changed, 36 insertions(+), 30 deletions(-) diff --git a/apps/dokploy/server/api/routers/cluster.ts b/apps/dokploy/server/api/routers/cluster.ts index dad67a3d0..3dc07935e 100644 --- a/apps/dokploy/server/api/routers/cluster.ts +++ b/apps/dokploy/server/api/routers/cluster.ts @@ -11,20 +11,6 @@ import { audit } from "@/server/api/utils/audit"; import { getLocalServerIp } from "@/server/wss/terminal"; import { createTRPCRouter, withPermission } from "../trpc"; -const assertServerBelongsToOrg = async ( - serverId: string | undefined, - activeOrganizationId: string, -) => { - if (!serverId) return; - const targetServer = await findServerById(serverId); - if (targetServer.organizationId !== activeOrganizationId) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "You don't have access to this server.", - }); - } -}; - export const clusterRouter = createTRPCRouter({ getNodes: withPermission("server", "read") .input( @@ -33,10 +19,15 @@ export const clusterRouter = createTRPCRouter({ }), ) .query(async ({ input, ctx }) => { - await assertServerBelongsToOrg( - input.serverId, - ctx.session.activeOrganizationId, - ); + if (input.serverId) { + const targetServer = await findServerById(input.serverId); + if (targetServer.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You don't have access to this server.", + }); + } + } const docker = await getRemoteDocker(input.serverId); const workers: DockerNode[] = await docker.listNodes(); return workers; @@ -50,10 +41,15 @@ export const clusterRouter = createTRPCRouter({ }), ) .mutation(async ({ input, ctx }) => { - await assertServerBelongsToOrg( - input.serverId, - ctx.session.activeOrganizationId, - ); + if (input.serverId) { + const targetServer = await findServerById(input.serverId); + if (targetServer.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You don't have access to this server.", + }); + } + } try { const drainCommand = `docker node update --availability drain ${input.nodeId}`; const removeCommand = `docker node rm ${input.nodeId} --force`; @@ -88,10 +84,15 @@ export const clusterRouter = createTRPCRouter({ }), ) .query(async ({ input, ctx }) => { - await assertServerBelongsToOrg( - input.serverId, - ctx.session.activeOrganizationId, - ); + if (input.serverId) { + const targetServer = await findServerById(input.serverId); + if (targetServer.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You don't have access to this server.", + }); + } + } const docker = await getRemoteDocker(input.serverId); const result = await docker.swarmInspect(); const docker_version = await docker.version(); @@ -115,10 +116,15 @@ export const clusterRouter = createTRPCRouter({ }), ) .query(async ({ input, ctx }) => { - await assertServerBelongsToOrg( - input.serverId, - ctx.session.activeOrganizationId, - ); + if (input.serverId) { + const targetServer = await findServerById(input.serverId); + if (targetServer.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You don't have access to this server.", + }); + } + } const docker = await getRemoteDocker(input.serverId); const result = await docker.swarmInspect(); const docker_version = await docker.version();