Skip to content

Commit ffb8d43

Browse files
authored
Merge pull request #4869 from Dokploy/fix/schedule-host-authz-bypass
fix(security): host-schedule owner/admin bypass via applicationId (member → root)
2 parents 393fb92 + f339805 commit ffb8d43

3 files changed

Lines changed: 82 additions & 157 deletions

File tree

‎apps/dokploy/server/api/routers/schedule.ts‎

Lines changed: 35 additions & 155 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
findMemberByUserId,
1414
} from "@dokploy/server/services/permission";
1515
import {
16+
assertHostScheduleAccess,
1617
createSchedule,
1718
deleteSchedule,
1819
findScheduleById,
@@ -31,6 +32,8 @@ export const scheduleRouter = createTRPCRouter({
3132
create: protectedProcedure
3233
.input(createScheduleSchema)
3334
.mutation(async ({ input, ctx }) => {
35+
await assertHostScheduleAccess(ctx, input.scheduleType, input.serverId);
36+
3437
const serviceId = input.applicationId || input.composeId;
3538
if (serviceId) {
3639
await checkServicePermissionAndAccess(ctx, serviceId, {
@@ -44,51 +47,14 @@ export const scheduleRouter = createTRPCRouter({
4447
);
4548
}
4649
} else {
47-
if (input.scheduleType === "dokploy-server" && IS_CLOUD) {
48-
throw new TRPCError({
49-
code: "FORBIDDEN",
50-
message:
51-
"Host-level schedules are not available in the cloud version.",
52-
});
53-
}
54-
5550
await checkPermission(ctx, { schedule: ["create"] });
5651

57-
if (
58-
input.scheduleType === "server" ||
59-
input.scheduleType === "dokploy-server"
60-
) {
61-
const member = await findMemberByUserId(
62-
ctx.user.id,
52+
if (IS_CLOUD && input.scheduleType === "server" && input.serverId) {
53+
await assertScheduledJobLimit(
6354
ctx.session.activeOrganizationId,
55+
"server",
56+
input.serverId,
6457
);
65-
if (member.role !== "owner" && member.role !== "admin") {
66-
throw new TRPCError({
67-
code: "FORBIDDEN",
68-
message:
69-
"Only owners and admins can manage server-level schedules.",
70-
});
71-
}
72-
}
73-
74-
if (input.scheduleType === "server" && input.serverId) {
75-
const targetServer = await findServerById(input.serverId);
76-
if (
77-
targetServer.organizationId !== ctx.session.activeOrganizationId
78-
) {
79-
throw new TRPCError({
80-
code: "UNAUTHORIZED",
81-
message: "You don't have access to this server.",
82-
});
83-
}
84-
85-
if (IS_CLOUD) {
86-
await assertScheduledJobLimit(
87-
ctx.session.activeOrganizationId,
88-
"server",
89-
input.serverId,
90-
);
91-
}
9258
}
9359
}
9460
const newSchedule = await createSchedule({
@@ -135,54 +101,30 @@ export const scheduleRouter = createTRPCRouter({
135101
});
136102
}
137103

104+
await assertHostScheduleAccess(
105+
ctx,
106+
existingSchedule.scheduleType,
107+
existingSchedule.serverId,
108+
);
109+
if (
110+
input.scheduleType &&
111+
input.scheduleType !== existingSchedule.scheduleType
112+
) {
113+
await assertHostScheduleAccess(
114+
ctx,
115+
input.scheduleType,
116+
input.serverId ?? existingSchedule.serverId,
117+
);
118+
}
119+
138120
const serviceId =
139121
existingSchedule.applicationId || existingSchedule.composeId;
140122
if (serviceId) {
141123
await checkServicePermissionAndAccess(ctx, serviceId, {
142124
schedule: ["update"],
143125
});
144126
} else {
145-
if (existingSchedule.scheduleType === "dokploy-server" && IS_CLOUD) {
146-
throw new TRPCError({
147-
code: "FORBIDDEN",
148-
message:
149-
"Host-level schedules are not available in the cloud version.",
150-
});
151-
}
152-
153127
await checkPermission(ctx, { schedule: ["update"] });
154-
155-
if (
156-
existingSchedule.scheduleType === "server" ||
157-
existingSchedule.scheduleType === "dokploy-server"
158-
) {
159-
const member = await findMemberByUserId(
160-
ctx.user.id,
161-
ctx.session.activeOrganizationId,
162-
);
163-
if (member.role !== "owner" && member.role !== "admin") {
164-
throw new TRPCError({
165-
code: "FORBIDDEN",
166-
message:
167-
"Only owners and admins can manage server-level schedules.",
168-
});
169-
}
170-
}
171-
172-
if (
173-
existingSchedule.scheduleType === "server" &&
174-
existingSchedule.serverId
175-
) {
176-
const targetServer = await findServerById(existingSchedule.serverId);
177-
if (
178-
targetServer.organizationId !== ctx.session.activeOrganizationId
179-
) {
180-
throw new TRPCError({
181-
code: "UNAUTHORIZED",
182-
message: "You don't have access to this server.",
183-
});
184-
}
185-
}
186128
}
187129
const updatedSchedule = await updateSchedule(input);
188130

@@ -222,50 +164,19 @@ export const scheduleRouter = createTRPCRouter({
222164
.input(z.object({ scheduleId: z.string() }))
223165
.mutation(async ({ input, ctx }) => {
224166
const scheduleItem = await findScheduleById(input.scheduleId);
167+
await assertHostScheduleAccess(
168+
ctx,
169+
scheduleItem.scheduleType,
170+
scheduleItem.serverId,
171+
);
172+
225173
const serviceId = scheduleItem.applicationId || scheduleItem.composeId;
226174
if (serviceId) {
227175
await checkServicePermissionAndAccess(ctx, serviceId, {
228176
schedule: ["delete"],
229177
});
230178
} else {
231-
if (scheduleItem.scheduleType === "dokploy-server" && IS_CLOUD) {
232-
throw new TRPCError({
233-
code: "FORBIDDEN",
234-
message:
235-
"Host-level schedules are not available in the cloud version.",
236-
});
237-
}
238-
239179
await checkPermission(ctx, { schedule: ["delete"] });
240-
241-
if (
242-
scheduleItem.scheduleType === "server" ||
243-
scheduleItem.scheduleType === "dokploy-server"
244-
) {
245-
const member = await findMemberByUserId(
246-
ctx.user.id,
247-
ctx.session.activeOrganizationId,
248-
);
249-
if (member.role !== "owner" && member.role !== "admin") {
250-
throw new TRPCError({
251-
code: "FORBIDDEN",
252-
message:
253-
"Only owners and admins can manage server-level schedules.",
254-
});
255-
}
256-
}
257-
258-
if (scheduleItem.scheduleType === "server" && scheduleItem.serverId) {
259-
const targetServer = await findServerById(scheduleItem.serverId);
260-
if (
261-
targetServer.organizationId !== ctx.session.activeOrganizationId
262-
) {
263-
throw new TRPCError({
264-
code: "UNAUTHORIZED",
265-
message: "You don't have access to this server.",
266-
});
267-
}
268-
}
269180
}
270181
await deleteSchedule(input.scheduleId);
271182

@@ -389,50 +300,19 @@ export const scheduleRouter = createTRPCRouter({
389300
.input(z.object({ scheduleId: z.string().min(1) }))
390301
.mutation(async ({ input, ctx }) => {
391302
const scheduleItem = await findScheduleById(input.scheduleId);
303+
await assertHostScheduleAccess(
304+
ctx,
305+
scheduleItem.scheduleType,
306+
scheduleItem.serverId,
307+
);
308+
392309
const serviceId = scheduleItem.applicationId || scheduleItem.composeId;
393310
if (serviceId) {
394311
await checkServicePermissionAndAccess(ctx, serviceId, {
395312
schedule: ["create"],
396313
});
397314
} else {
398-
if (scheduleItem.scheduleType === "dokploy-server" && IS_CLOUD) {
399-
throw new TRPCError({
400-
code: "FORBIDDEN",
401-
message:
402-
"Host-level schedules are not available in the cloud version.",
403-
});
404-
}
405-
406315
await checkPermission(ctx, { schedule: ["create"] });
407-
408-
if (
409-
scheduleItem.scheduleType === "server" ||
410-
scheduleItem.scheduleType === "dokploy-server"
411-
) {
412-
const member = await findMemberByUserId(
413-
ctx.user.id,
414-
ctx.session.activeOrganizationId,
415-
);
416-
if (member.role !== "owner" && member.role !== "admin") {
417-
throw new TRPCError({
418-
code: "FORBIDDEN",
419-
message:
420-
"Only owners and admins can manage server-level schedules.",
421-
});
422-
}
423-
}
424-
425-
if (scheduleItem.scheduleType === "server" && scheduleItem.serverId) {
426-
const targetServer = await findServerById(scheduleItem.serverId);
427-
if (
428-
targetServer.organizationId !== ctx.session.activeOrganizationId
429-
) {
430-
throw new TRPCError({
431-
code: "UNAUTHORIZED",
432-
message: "You don't have access to this server.",
433-
});
434-
}
435-
}
436316
}
437317
try {
438318
await runCommand(input.scheduleId);

‎packages/server/src/db/schema/schedule.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export const schedules = pgTable("schedule", {
5757
});
5858

5959
export type Schedule = typeof schedules.$inferSelect;
60+
export type ScheduleType = Schedule["scheduleType"];
6061

6162
export const schedulesRelations = relations(schedules, ({ one, many }) => ({
6263
application: one(applications, {
@@ -78,7 +79,9 @@ export const schedulesRelations = relations(schedules, ({ one, many }) => ({
7879
deployments: many(deployments),
7980
}));
8081

81-
export const createScheduleSchema = createInsertSchema(schedules);
82+
export const createScheduleSchema = createInsertSchema(schedules, {
83+
scheduleType: z.enum(["application", "compose", "server", "dokploy-server"]),
84+
});
8285

8386
export const updateScheduleSchema = createScheduleSchema.extend({
8487
scheduleId: z.string().min(1),

‎packages/server/src/services/schedule.ts‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import path from "node:path";
22
import { TRPCError } from "@trpc/server";
33
import { eq } from "drizzle-orm";
44
import type { z } from "zod";
5-
import { paths } from "../constants";
5+
import { IS_CLOUD, paths } from "../constants";
66
import { db } from "../db";
77
import type {
88
createScheduleSchema,
@@ -11,9 +11,51 @@ import type {
1111
import { type Schedule, schedules } from "../db/schema/schedule";
1212
import { encodeBase64 } from "../utils/docker/utils";
1313
import { execAsync, execAsyncRemote } from "../utils/process/execAsync";
14+
import { findMemberByUserId } from "./permission";
15+
import { findServerById } from "./server";
1416

1517
export type ScheduleExtended = Awaited<ReturnType<typeof findScheduleById>>;
1618

19+
// Host-level schedules (server / dokploy-server) run their script as root on the
20+
// host and must stay restricted to owners/admins, regardless of whether the
21+
// request is also tied to a service. Attaching an accessible applicationId must
22+
// not downgrade this to a service-access check.
23+
export const assertHostScheduleAccess = async (
24+
ctx: { user: { id: string }; session: { activeOrganizationId: string } },
25+
scheduleType: Schedule["scheduleType"] | null | undefined,
26+
serverId: string | null | undefined,
27+
) => {
28+
if (scheduleType !== "server" && scheduleType !== "dokploy-server") return;
29+
30+
if (scheduleType === "dokploy-server" && IS_CLOUD) {
31+
throw new TRPCError({
32+
code: "FORBIDDEN",
33+
message: "Host-level schedules are not available in the cloud version.",
34+
});
35+
}
36+
37+
const member = await findMemberByUserId(
38+
ctx.user.id,
39+
ctx.session.activeOrganizationId,
40+
);
41+
if (member.role !== "owner" && member.role !== "admin") {
42+
throw new TRPCError({
43+
code: "FORBIDDEN",
44+
message: "Only owners and admins can manage server-level schedules.",
45+
});
46+
}
47+
48+
if (scheduleType === "server" && serverId) {
49+
const targetServer = await findServerById(serverId);
50+
if (targetServer.organizationId !== ctx.session.activeOrganizationId) {
51+
throw new TRPCError({
52+
code: "UNAUTHORIZED",
53+
message: "You don't have access to this server.",
54+
});
55+
}
56+
}
57+
};
58+
1759
export const createSchedule = async (
1860
input: z.infer<typeof createScheduleSchema>,
1961
) => {

0 commit comments

Comments
 (0)