mirror of
https://github.com/andwati/dokploy.git
synced 2026-11-05 11:42:17 +00:00
Merge pull request #4869 from Dokploy/fix/schedule-host-authz-bypass
fix(security): host-schedule owner/admin bypass via applicationId (member → root)
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
|||||||
findMemberByUserId,
|
findMemberByUserId,
|
||||||
} from "@dokploy/server/services/permission";
|
} from "@dokploy/server/services/permission";
|
||||||
import {
|
import {
|
||||||
|
assertHostScheduleAccess,
|
||||||
createSchedule,
|
createSchedule,
|
||||||
deleteSchedule,
|
deleteSchedule,
|
||||||
findScheduleById,
|
findScheduleById,
|
||||||
@@ -31,6 +32,8 @@ export const scheduleRouter = createTRPCRouter({
|
|||||||
create: protectedProcedure
|
create: protectedProcedure
|
||||||
.input(createScheduleSchema)
|
.input(createScheduleSchema)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
await assertHostScheduleAccess(ctx, input.scheduleType, input.serverId);
|
||||||
|
|
||||||
const serviceId = input.applicationId || input.composeId;
|
const serviceId = input.applicationId || input.composeId;
|
||||||
if (serviceId) {
|
if (serviceId) {
|
||||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||||
@@ -44,51 +47,14 @@ export const scheduleRouter = createTRPCRouter({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} 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"] });
|
await checkPermission(ctx, { schedule: ["create"] });
|
||||||
|
|
||||||
if (
|
if (IS_CLOUD && input.scheduleType === "server" && input.serverId) {
|
||||||
input.scheduleType === "server" ||
|
await assertScheduledJobLimit(
|
||||||
input.scheduleType === "dokploy-server"
|
|
||||||
) {
|
|
||||||
const member = await findMemberByUserId(
|
|
||||||
ctx.user.id,
|
|
||||||
ctx.session.activeOrganizationId,
|
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({
|
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 =
|
const serviceId =
|
||||||
existingSchedule.applicationId || existingSchedule.composeId;
|
existingSchedule.applicationId || existingSchedule.composeId;
|
||||||
if (serviceId) {
|
if (serviceId) {
|
||||||
@@ -142,47 +124,7 @@ export const scheduleRouter = createTRPCRouter({
|
|||||||
schedule: ["update"],
|
schedule: ["update"],
|
||||||
});
|
});
|
||||||
} else {
|
} 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"] });
|
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);
|
const updatedSchedule = await updateSchedule(input);
|
||||||
|
|
||||||
@@ -222,50 +164,19 @@ export const scheduleRouter = createTRPCRouter({
|
|||||||
.input(z.object({ scheduleId: z.string() }))
|
.input(z.object({ scheduleId: z.string() }))
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const scheduleItem = await findScheduleById(input.scheduleId);
|
const scheduleItem = await findScheduleById(input.scheduleId);
|
||||||
|
await assertHostScheduleAccess(
|
||||||
|
ctx,
|
||||||
|
scheduleItem.scheduleType,
|
||||||
|
scheduleItem.serverId,
|
||||||
|
);
|
||||||
|
|
||||||
const serviceId = scheduleItem.applicationId || scheduleItem.composeId;
|
const serviceId = scheduleItem.applicationId || scheduleItem.composeId;
|
||||||
if (serviceId) {
|
if (serviceId) {
|
||||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||||
schedule: ["delete"],
|
schedule: ["delete"],
|
||||||
});
|
});
|
||||||
} else {
|
} 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"] });
|
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);
|
await deleteSchedule(input.scheduleId);
|
||||||
|
|
||||||
@@ -389,50 +300,19 @@ export const scheduleRouter = createTRPCRouter({
|
|||||||
.input(z.object({ scheduleId: z.string().min(1) }))
|
.input(z.object({ scheduleId: z.string().min(1) }))
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const scheduleItem = await findScheduleById(input.scheduleId);
|
const scheduleItem = await findScheduleById(input.scheduleId);
|
||||||
|
await assertHostScheduleAccess(
|
||||||
|
ctx,
|
||||||
|
scheduleItem.scheduleType,
|
||||||
|
scheduleItem.serverId,
|
||||||
|
);
|
||||||
|
|
||||||
const serviceId = scheduleItem.applicationId || scheduleItem.composeId;
|
const serviceId = scheduleItem.applicationId || scheduleItem.composeId;
|
||||||
if (serviceId) {
|
if (serviceId) {
|
||||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||||
schedule: ["create"],
|
schedule: ["create"],
|
||||||
});
|
});
|
||||||
} else {
|
} 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"] });
|
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 {
|
try {
|
||||||
await runCommand(input.scheduleId);
|
await runCommand(input.scheduleId);
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export const schedules = pgTable("schedule", {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type Schedule = typeof schedules.$inferSelect;
|
export type Schedule = typeof schedules.$inferSelect;
|
||||||
|
export type ScheduleType = Schedule["scheduleType"];
|
||||||
|
|
||||||
export const schedulesRelations = relations(schedules, ({ one, many }) => ({
|
export const schedulesRelations = relations(schedules, ({ one, many }) => ({
|
||||||
application: one(applications, {
|
application: one(applications, {
|
||||||
@@ -78,7 +79,9 @@ export const schedulesRelations = relations(schedules, ({ one, many }) => ({
|
|||||||
deployments: many(deployments),
|
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({
|
export const updateScheduleSchema = createScheduleSchema.extend({
|
||||||
scheduleId: z.string().min(1),
|
scheduleId: z.string().min(1),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import path from "node:path";
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import { paths } from "../constants";
|
import { IS_CLOUD, paths } from "../constants";
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import type {
|
import type {
|
||||||
createScheduleSchema,
|
createScheduleSchema,
|
||||||
@@ -11,9 +11,51 @@ import type {
|
|||||||
import { type Schedule, schedules } from "../db/schema/schedule";
|
import { type Schedule, schedules } from "../db/schema/schedule";
|
||||||
import { encodeBase64 } from "../utils/docker/utils";
|
import { encodeBase64 } from "../utils/docker/utils";
|
||||||
import { execAsync, execAsyncRemote } from "../utils/process/execAsync";
|
import { execAsync, execAsyncRemote } from "../utils/process/execAsync";
|
||||||
|
import { findMemberByUserId } from "./permission";
|
||||||
|
import { findServerById } from "./server";
|
||||||
|
|
||||||
export type ScheduleExtended = Awaited<ReturnType<typeof findScheduleById>>;
|
export type ScheduleExtended = Awaited<ReturnType<typeof findScheduleById>>;
|
||||||
|
|
||||||
|
// 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: Schedule["scheduleType"] | null | 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 (
|
export const createSchedule = async (
|
||||||
input: z.infer<typeof createScheduleSchema>,
|
input: z.infer<typeof createScheduleSchema>,
|
||||||
) => {
|
) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user