From 018e2b153e564cf9af163d47ce4a1ee409dfb848 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Fri, 24 Apr 2026 12:44:42 -0600 Subject: [PATCH] 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) {