diff --git a/apps/dokploy/server/api/routers/mount.ts b/apps/dokploy/server/api/routers/mount.ts index 9d9e0a06e..c4ec62ab7 100644 --- a/apps/dokploy/server/api/routers/mount.ts +++ b/apps/dokploy/server/api/routers/mount.ts @@ -12,12 +12,14 @@ import { findPostgresById, findRedisById, getServiceContainer, + isDangerousBindMountPath, updateMount, } from "@dokploy/server"; import type { ServiceType } from "@dokploy/server/db/schema/mount"; import { checkServiceAccess, checkServicePermissionAndAccess, + isPrivilegedOrgRole, } from "@dokploy/server/services/permission"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; @@ -73,6 +75,23 @@ async function getServiceOrganizationId( } } +async function assertSafeBindMountPath( + ctx: { session: { userId: string; activeOrganizationId: string } }, + type: string | undefined, + hostPath: string | null | undefined, +) { + if (type !== "bind" || !hostPath) return; + if (!isDangerousBindMountPath(hostPath)) return; + + if (!(await isPrivilegedOrgRole(ctx.session))) { + throw new TRPCError({ + code: "FORBIDDEN", + message: + "This host path is reserved and can't be used in a bind mount. Only an organization owner or admin can do that.", + }); + } +} + export const mountRouter = createTRPCRouter({ create: protectedProcedure .input(apiCreateMount) @@ -80,6 +99,7 @@ export const mountRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.serviceId, { volume: ["create"], }); + await assertSafeBindMountPath(ctx, input.type, input.hostPath); const mount = await createMount(input); await audit(ctx, { action: "create", @@ -153,6 +173,11 @@ export const mountRouter = createTRPCRouter({ volume: ["create"], }); } + await assertSafeBindMountPath( + ctx, + input.type ?? mount.type, + input.hostPath ?? mount.hostPath, + ); await audit(ctx, { action: "update", resourceType: "mount", diff --git a/packages/server/src/services/mount.ts b/packages/server/src/services/mount.ts index f85e2b2d8..e79992933 100644 --- a/packages/server/src/services/mount.ts +++ b/packages/server/src/services/mount.ts @@ -23,6 +23,31 @@ import type { z } from "zod"; export type Mount = typeof mounts.$inferSelect; +// Absolute host paths a "bind" mount must never be allowed to target for a +// non-owner/admin caller - each one gives a way off the container onto the +// Dokploy control host (docker.sock -> full daemon control, /etc/dokploy -> +// Dokploy's own config/certs, /root //boot/proc/sys -> host takeover). +const DANGEROUS_HOST_PATH_PREFIXES = [ + "/var/run/docker.sock", + "/run/docker.sock", + "/var/lib/docker", + "/etc/dokploy", + "/root", + "/boot", + "/proc", + "/sys", +]; + +export const isDangerousBindMountPath = (hostPath: string): boolean => { + // Resolve against "/" so relative segments and ".." traversal tricks + // (e.g. "../../var/run/docker.sock") can't sneak past the prefix check. + const normalized = path.posix.resolve("/", hostPath.trim()); + if (normalized === "/") return true; + return DANGEROUS_HOST_PATH_PREFIXES.some( + (prefix) => normalized === prefix || normalized.startsWith(`${prefix}/`), + ); +}; + export const createMount = async (input: z.infer) => { try { const { serviceId, ...rest } = input; diff --git a/packages/server/src/services/permission.ts b/packages/server/src/services/permission.ts index 9de4152f5..1c5b622f7 100644 --- a/packages/server/src/services/permission.ts +++ b/packages/server/src/services/permission.ts @@ -434,3 +434,22 @@ export const findMemberByUserId = async ( } return result; }; + +// Some actions (e.g. a bind mount targeting a sensitive host path) must stay +// restricted to the org's owner/admin even if a custom role was granted the +// underlying resource permission (e.g. volume:create) - that permission only +// covers ordinary use, not host takeover. +export const isPrivilegedOrgRole = async (session: { + userId: string; + activeOrganizationId: string; +}): Promise => { + const memberRecord = await db.query.member.findFirst({ + where: and( + eq(member.userId, session.userId), + eq(member.organizationId, session.activeOrganizationId), + ), + columns: { role: true }, + }); + + return memberRecord?.role === "owner" || memberRecord?.role === "admin"; +};