From 1e3f10bd22c1c28a7b65a2d7ac15a0a5e47599eb Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Mon, 20 Jul 2026 15:22:06 -0600 Subject: [PATCH 1/3] fix(security): enforce owner/admin gate on host schedules regardless of service link Host-level schedules (server / dokploy-server) run their script as root on the host. The owner/admin gate only ran in the no-service branch, so a member could attach an accessible applicationId to a dokploy-server schedule and skip it, gaining root via schedule.runManually. Extract assertHostScheduleAccess into the schedule service and call it before the service-access branch in create/update/delete/runManually so the host-level authorization always applies. --- apps/dokploy/server/api/routers/schedule.ts | 190 ++++---------------- packages/server/src/services/schedule.ts | 44 ++++- 2 files changed, 78 insertions(+), 156 deletions(-) diff --git a/apps/dokploy/server/api/routers/schedule.ts b/apps/dokploy/server/api/routers/schedule.ts index 2bec813a7..9f0c16b1e 100644 --- a/apps/dokploy/server/api/routers/schedule.ts +++ b/apps/dokploy/server/api/routers/schedule.ts @@ -13,6 +13,7 @@ import { findMemberByUserId, } from "@dokploy/server/services/permission"; import { + assertHostScheduleAccess, createSchedule, deleteSchedule, findScheduleById, @@ -31,6 +32,8 @@ export const scheduleRouter = createTRPCRouter({ create: protectedProcedure .input(createScheduleSchema) .mutation(async ({ input, ctx }) => { + await assertHostScheduleAccess(ctx, input.scheduleType, input.serverId); + const serviceId = input.applicationId || input.composeId; if (serviceId) { await checkServicePermissionAndAccess(ctx, serviceId, { @@ -44,51 +47,14 @@ export const scheduleRouter = createTRPCRouter({ ); } } else { - if (input.scheduleType === "dokploy-server" && IS_CLOUD) { - throw new TRPCError({ - code: "FORBIDDEN", - message: - "Host-level schedules are not available in the cloud version.", - }); - } - await checkPermission(ctx, { schedule: ["create"] }); - if ( - input.scheduleType === "server" || - input.scheduleType === "dokploy-server" - ) { - const member = await findMemberByUserId( - ctx.user.id, + if (IS_CLOUD && input.scheduleType === "server" && input.serverId) { + await assertScheduledJobLimit( ctx.session.activeOrganizationId, + "server", + input.serverId, ); - if (member.role !== "owner" && member.role !== "admin") { - throw new TRPCError({ - code: "FORBIDDEN", - message: - "Only owners and admins can manage server-level schedules.", - }); - } - } - - if (input.scheduleType === "server" && 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.", - }); - } - - if (IS_CLOUD) { - await assertScheduledJobLimit( - ctx.session.activeOrganizationId, - "server", - input.serverId, - ); - } } } const newSchedule = await createSchedule({ @@ -135,6 +101,22 @@ export const scheduleRouter = createTRPCRouter({ }); } + await assertHostScheduleAccess( + ctx, + existingSchedule.scheduleType, + existingSchedule.serverId, + ); + if ( + input.scheduleType && + input.scheduleType !== existingSchedule.scheduleType + ) { + await assertHostScheduleAccess( + ctx, + input.scheduleType, + input.serverId ?? existingSchedule.serverId, + ); + } + const serviceId = existingSchedule.applicationId || existingSchedule.composeId; if (serviceId) { @@ -142,47 +124,7 @@ export const scheduleRouter = createTRPCRouter({ schedule: ["update"], }); } else { - if (existingSchedule.scheduleType === "dokploy-server" && IS_CLOUD) { - throw new TRPCError({ - code: "FORBIDDEN", - message: - "Host-level schedules are not available in the cloud version.", - }); - } - await checkPermission(ctx, { schedule: ["update"] }); - - if ( - existingSchedule.scheduleType === "server" || - existingSchedule.scheduleType === "dokploy-server" - ) { - const member = await findMemberByUserId( - ctx.user.id, - ctx.session.activeOrganizationId, - ); - if (member.role !== "owner" && member.role !== "admin") { - throw new TRPCError({ - code: "FORBIDDEN", - message: - "Only owners and admins can manage server-level schedules.", - }); - } - } - - if ( - existingSchedule.scheduleType === "server" && - existingSchedule.serverId - ) { - const targetServer = await findServerById(existingSchedule.serverId); - if ( - targetServer.organizationId !== ctx.session.activeOrganizationId - ) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "You don't have access to this server.", - }); - } - } } const updatedSchedule = await updateSchedule(input); @@ -222,50 +164,19 @@ export const scheduleRouter = createTRPCRouter({ .input(z.object({ scheduleId: z.string() })) .mutation(async ({ input, ctx }) => { const scheduleItem = await findScheduleById(input.scheduleId); + await assertHostScheduleAccess( + ctx, + scheduleItem.scheduleType, + scheduleItem.serverId, + ); + const serviceId = scheduleItem.applicationId || scheduleItem.composeId; if (serviceId) { await checkServicePermissionAndAccess(ctx, serviceId, { schedule: ["delete"], }); } else { - if (scheduleItem.scheduleType === "dokploy-server" && IS_CLOUD) { - throw new TRPCError({ - code: "FORBIDDEN", - message: - "Host-level schedules are not available in the cloud version.", - }); - } - await checkPermission(ctx, { schedule: ["delete"] }); - - if ( - scheduleItem.scheduleType === "server" || - scheduleItem.scheduleType === "dokploy-server" - ) { - const member = await findMemberByUserId( - ctx.user.id, - ctx.session.activeOrganizationId, - ); - if (member.role !== "owner" && member.role !== "admin") { - throw new TRPCError({ - code: "FORBIDDEN", - message: - "Only owners and admins can manage server-level schedules.", - }); - } - } - - if (scheduleItem.scheduleType === "server" && scheduleItem.serverId) { - const targetServer = await findServerById(scheduleItem.serverId); - if ( - targetServer.organizationId !== ctx.session.activeOrganizationId - ) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "You don't have access to this server.", - }); - } - } } await deleteSchedule(input.scheduleId); @@ -389,50 +300,19 @@ export const scheduleRouter = createTRPCRouter({ .input(z.object({ scheduleId: z.string().min(1) })) .mutation(async ({ input, ctx }) => { const scheduleItem = await findScheduleById(input.scheduleId); + await assertHostScheduleAccess( + ctx, + scheduleItem.scheduleType, + scheduleItem.serverId, + ); + const serviceId = scheduleItem.applicationId || scheduleItem.composeId; if (serviceId) { await checkServicePermissionAndAccess(ctx, serviceId, { schedule: ["create"], }); } else { - if (scheduleItem.scheduleType === "dokploy-server" && IS_CLOUD) { - throw new TRPCError({ - code: "FORBIDDEN", - message: - "Host-level schedules are not available in the cloud version.", - }); - } - await checkPermission(ctx, { schedule: ["create"] }); - - if ( - scheduleItem.scheduleType === "server" || - scheduleItem.scheduleType === "dokploy-server" - ) { - const member = await findMemberByUserId( - ctx.user.id, - ctx.session.activeOrganizationId, - ); - if (member.role !== "owner" && member.role !== "admin") { - throw new TRPCError({ - code: "FORBIDDEN", - message: - "Only owners and admins can manage server-level schedules.", - }); - } - } - - if (scheduleItem.scheduleType === "server" && scheduleItem.serverId) { - const targetServer = await findServerById(scheduleItem.serverId); - if ( - targetServer.organizationId !== ctx.session.activeOrganizationId - ) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "You don't have access to this server.", - }); - } - } } try { await runCommand(input.scheduleId); diff --git a/packages/server/src/services/schedule.ts b/packages/server/src/services/schedule.ts index b8751671b..db6d51ecd 100644 --- a/packages/server/src/services/schedule.ts +++ b/packages/server/src/services/schedule.ts @@ -2,7 +2,7 @@ import path from "node:path"; import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; import type { z } from "zod"; -import { paths } from "../constants"; +import { IS_CLOUD, paths } from "../constants"; import { db } from "../db"; import type { createScheduleSchema, @@ -11,9 +11,51 @@ import type { import { type Schedule, schedules } from "../db/schema/schedule"; import { encodeBase64 } from "../utils/docker/utils"; import { execAsync, execAsyncRemote } from "../utils/process/execAsync"; +import { findMemberByUserId } from "./permission"; +import { findServerById } from "./server"; export type ScheduleExtended = Awaited>; +// Host-level schedules (server / dokploy-server) run their script as root on the +// host and must stay restricted to owners/admins, regardless of whether the +// request is also tied to a service. Attaching an accessible applicationId must +// not downgrade this to a service-access check. +export const assertHostScheduleAccess = async ( + ctx: { user: { id: string }; session: { activeOrganizationId: string } }, + scheduleType: string | undefined, + serverId: string | null | undefined, +) => { + if (scheduleType !== "server" && scheduleType !== "dokploy-server") return; + + if (scheduleType === "dokploy-server" && IS_CLOUD) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Host-level schedules are not available in the cloud version.", + }); + } + + const member = await findMemberByUserId( + ctx.user.id, + ctx.session.activeOrganizationId, + ); + if (member.role !== "owner" && member.role !== "admin") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Only owners and admins can manage server-level schedules.", + }); + } + + if (scheduleType === "server" && serverId) { + const targetServer = await findServerById(serverId); + if (targetServer.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You don't have access to this server.", + }); + } + } +}; + export const createSchedule = async ( input: z.infer, ) => { From 8fe3294a08fa0f4908ac9459fd4f465795eb489c Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Mon, 20 Jul 2026 15:30:13 -0600 Subject: [PATCH 2/3] fix(schedule): give scheduleType a clean enum type for the host-access gate createInsertSchema inferred scheduleType as a broadened union (drizzle-zod 0.5.1), so passing input.scheduleType into assertHostScheduleAccess failed typecheck on a cold build. Refine the insert schema's scheduleType to a plain z.enum and type the helper param as the schedule enum. --- packages/server/src/db/schema/schedule.ts | 10 +++++++++- packages/server/src/services/schedule.ts | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/server/src/db/schema/schedule.ts b/packages/server/src/db/schema/schedule.ts index 4c679ae36..6e7395dcb 100644 --- a/packages/server/src/db/schema/schedule.ts +++ b/packages/server/src/db/schema/schedule.ts @@ -57,6 +57,7 @@ export const schedules = pgTable("schedule", { }); export type Schedule = typeof schedules.$inferSelect; +export type ScheduleType = Schedule["scheduleType"]; export const schedulesRelations = relations(schedules, ({ one, many }) => ({ application: one(applications, { @@ -78,7 +79,14 @@ export const schedulesRelations = relations(schedules, ({ one, many }) => ({ deployments: many(deployments), })); -export const createScheduleSchema = createInsertSchema(schedules); +export const createScheduleSchema = createInsertSchema(schedules, { + scheduleType: z.enum([ + "application", + "compose", + "server", + "dokploy-server", + ]), +}); export const updateScheduleSchema = createScheduleSchema.extend({ scheduleId: z.string().min(1), diff --git a/packages/server/src/services/schedule.ts b/packages/server/src/services/schedule.ts index db6d51ecd..c94973e02 100644 --- a/packages/server/src/services/schedule.ts +++ b/packages/server/src/services/schedule.ts @@ -22,7 +22,7 @@ export type ScheduleExtended = Awaited>; // not downgrade this to a service-access check. export const assertHostScheduleAccess = async ( ctx: { user: { id: string }; session: { activeOrganizationId: string } }, - scheduleType: string | undefined, + scheduleType: Schedule["scheduleType"] | null | undefined, serverId: string | null | undefined, ) => { if (scheduleType !== "server" && scheduleType !== "dokploy-server") return; From f339805ddfddea56a014a4006d91c9de1e3bd4ba Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:30:52 +0000 Subject: [PATCH 3/3] [autofix.ci] apply automated fixes --- packages/server/src/db/schema/schedule.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/server/src/db/schema/schedule.ts b/packages/server/src/db/schema/schedule.ts index 6e7395dcb..55c831f54 100644 --- a/packages/server/src/db/schema/schedule.ts +++ b/packages/server/src/db/schema/schedule.ts @@ -80,12 +80,7 @@ export const schedulesRelations = relations(schedules, ({ one, many }) => ({ })); export const createScheduleSchema = createInsertSchema(schedules, { - scheduleType: z.enum([ - "application", - "compose", - "server", - "dokploy-server", - ]), + scheduleType: z.enum(["application", "compose", "server", "dokploy-server"]), }); export const updateScheduleSchema = createScheduleSchema.extend({