fix: block bind mounts to sensitive host paths for non-owner/admin members

The Mounts feature (application/compose/database services) let any
member with volume:create access set an arbitrary bind mount hostPath,
with zero validation. Since generateBindMounts wires that path straight
into the Swarm service spec, a non-privileged member could mount
/var/run/docker.sock, /etc/dokploy, or other host-critical paths and
escalate to root on the Dokploy control host - same impact as bringing
your own docker-compose.yml with a docker.sock mount, but reachable
through the plain Mounts UI on any service type.

Add isDangerousBindMountPath() with a fixed blocklist of host-critical
paths (docker.sock, /etc/dokploy, /root, /boot, /proc, /sys, docker's
data dir) and isPrivilegedOrgRole(); mount.create/update now reject a
bind mount targeting one of those paths unless the caller is the org's
owner or admin. Single-user self-hosted installs are unaffected - the
sole user is always the owner.
This commit is contained in:
Mauricio Siu
2026-09-01 03:49:12 -06:00
parent c225188ed4
commit 18fb2547e4
3 changed files with 69 additions and 0 deletions
+25
View File
@@ -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",
+25
View File
@@ -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<typeof apiCreateMount>) => {
try {
const { serviceId, ...rest } = input;
@@ -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<boolean> => {
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";
};