fix(security): enforce org ownership check on dokploy-server schedule access

This commit is contained in:
detail-app[bot]
2026-09-04 02:48:34 +00:00
committed by GitHub
parent 1572008cdf
commit e40a098c7c
4 changed files with 325 additions and 24 deletions
@@ -0,0 +1,268 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// ---- Shared mutable mock state (hoisted so vi.mock closures can read it) ----
const mockState = vi.hoisted(() => ({
IS_CLOUD: false,
member: {
id: "member-1",
role: "owner",
userId: "user-1",
organizationId: "org-1",
accessedProjects: [] as string[],
accessedServices: [] as string[],
accessedEnvironments: [] as string[],
canCreateProjects: false,
canDeleteProjects: false,
canCreateServices: false,
canDeleteServices: false,
canCreateEnvironments: false,
canDeleteEnvironments: false,
canAccessToTraefikFiles: false,
canAccessToDocker: false,
canAccessToAPI: false,
canAccessToSSHKeys: false,
canAccessToGitProviders: false,
user: { id: "user-1", email: "test@test.com" },
},
server: { organizationId: "org-1" },
}));
vi.mock("@dokploy/server/constants", () => ({
get IS_CLOUD() {
return mockState.IS_CLOUD;
},
paths: () => ({ SCHEDULES_PATH: "/tmp/schedules" }),
}));
vi.mock("@dokploy/server/services/server", () => ({
findServerById: vi.fn(() => Promise.resolve(mockState.server)),
}));
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
hasValidLicense: vi.fn(() => Promise.resolve(false)),
}));
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
execAsync: vi.fn(),
execAsyncRemote: vi.fn(),
}));
vi.mock("@dokploy/server/utils/docker/utils", () => ({
encodeBase64: vi.fn(),
}));
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
member: {
findFirst: vi.fn(() => Promise.resolve(mockState.member)),
findMany: vi.fn(() => Promise.resolve([])),
},
organizationRole: {
findFirst: vi.fn(),
findMany: vi.fn(() => Promise.resolve([])),
},
},
},
}));
const { assertHostScheduleAccess } = await import(
"@dokploy/server/services/schedule"
);
const { updateScheduleSchema } = await import(
"@dokploy/server/db/schema/schedule"
);
const ctx = {
user: { id: "user-1" },
session: { activeOrganizationId: "org-1" },
};
beforeEach(() => {
vi.clearAllMocks();
mockState.IS_CLOUD = false;
mockState.member = {
...mockState.member,
role: "owner",
};
mockState.server = { organizationId: "org-1" };
});
describe("assertHostScheduleAccess — dokploy-server cross-org isolation", () => {
it("owner in own org with matching scheduleOrganizationId passes", async () => {
mockState.member = { ...mockState.member, role: "owner" };
await expect(
assertHostScheduleAccess(ctx, "dokploy-server", null, "org-1"),
).resolves.toBeUndefined();
});
it("admin in own org with matching scheduleOrganizationId passes", async () => {
mockState.member = { ...mockState.member, role: "admin" };
await expect(
assertHostScheduleAccess(ctx, "dokploy-server", null, "org-1"),
).resolves.toBeUndefined();
});
it("owner targeting a FOREIGN org schedule is rejected (IDOR fix)", async () => {
mockState.member = { ...mockState.member, role: "owner" };
await expect(
assertHostScheduleAccess(ctx, "dokploy-server", null, "org-2"),
).rejects.toThrow("You don't have access to this schedule.");
});
it("admin targeting a FOREIGN org schedule is rejected (IDOR fix)", async () => {
mockState.member = { ...mockState.member, role: "admin" };
await expect(
assertHostScheduleAccess(ctx, "dokploy-server", null, "org-2"),
).rejects.toThrow("You don't have access to this schedule.");
});
it("member is rejected even when the org matches (owner/admin gate)", async () => {
mockState.member = { ...mockState.member, role: "member" };
await expect(
assertHostScheduleAccess(ctx, "dokploy-server", null, "org-1"),
).rejects.toThrow(
"Only owners and admins can manage server-level schedules.",
);
});
it("member targeting a foreign org is rejected at the role gate, not the org check", async () => {
mockState.member = { ...mockState.member, role: "member" };
await expect(
assertHostScheduleAccess(ctx, "dokploy-server", null, "org-2"),
).rejects.toThrow(
"Only owners and admins can manage server-level schedules.",
);
});
it("create path (no scheduleOrganizationId) is allowed for owner", async () => {
mockState.member = { ...mockState.member, role: "owner" };
await expect(
assertHostScheduleAccess(ctx, "dokploy-server", null, undefined),
).resolves.toBeUndefined();
});
it("null scheduleOrganizationId is treated as no-op (allowed)", async () => {
mockState.member = { ...mockState.member, role: "owner" };
await expect(
assertHostScheduleAccess(ctx, "dokploy-server", null, null),
).resolves.toBeUndefined();
});
it("rejects dokploy-server on the cloud version", async () => {
mockState.IS_CLOUD = true;
mockState.member = { ...mockState.member, role: "owner" };
await expect(
assertHostScheduleAccess(ctx, "dokploy-server", null, "org-1"),
).rejects.toThrow(
"Host-level schedules are not available in the cloud version.",
);
});
it("is a no-op for application/compose scheduleType", async () => {
await expect(
assertHostScheduleAccess(ctx, "application", null, "org-2"),
).resolves.toBeUndefined();
await expect(
assertHostScheduleAccess(ctx, "compose", null, "org-2"),
).resolves.toBeUndefined();
});
});
describe("assertHostScheduleAccess — server branch (regression)", () => {
it("owner with a server in their own org passes", async () => {
mockState.member = { ...mockState.member, role: "owner" };
mockState.server = { organizationId: "org-1" };
await expect(
assertHostScheduleAccess(ctx, "server", "server-1", undefined),
).resolves.toBeUndefined();
});
it("owner with a server in a FOREIGN org is rejected", async () => {
mockState.member = { ...mockState.member, role: "owner" };
mockState.server = { organizationId: "org-2" };
await expect(
assertHostScheduleAccess(ctx, "server", "server-1", undefined),
).rejects.toThrow("You don't have access to this server.");
});
it("member cannot manage server schedules (owner/admin gate)", async () => {
mockState.member = { ...mockState.member, role: "member" };
mockState.server = { organizationId: "org-1" };
await expect(
assertHostScheduleAccess(ctx, "server", "server-1", undefined),
).rejects.toThrow(
"Only owners and admins can manage server-level schedules.",
);
});
it("passing a foreign scheduleOrganizationId is ignored for the server branch (server org is authoritative)", async () => {
mockState.member = { ...mockState.member, role: "owner" };
mockState.server = { organizationId: "org-1" };
await expect(
assertHostScheduleAccess(ctx, "server", "server-1", "org-2"),
).resolves.toBeUndefined();
});
});
describe("updateScheduleSchema strips ownership/identity fields", () => {
const validPayload = {
scheduleId: "sched-1",
name: "nightly backup",
command: "echo hello",
cronExpression: "* * * * *",
enabled: true,
shellType: "bash" as const,
script: "echo hello",
description: "d",
timezone: "UTC",
serviceName: null,
applicationId: null,
composeId: null,
};
const strippedKeys = [
"organizationId",
"scheduleType",
"serverId",
"appName",
"createdAt",
] as const;
for (const key of strippedKeys) {
it(`strips ${key} from a crafted update payload`, () => {
const parsed = updateScheduleSchema.safeParse({
...validPayload,
[key]: key === "scheduleType" ? "server" : "attacker-value",
});
expect(parsed.success).toBe(true);
if (parsed.success) {
expect(parsed.data).not.toHaveProperty(key);
}
});
}
it("keeps the legitimately mutable fields", () => {
const parsed = updateScheduleSchema.safeParse(validPayload);
expect(parsed.success).toBe(true);
if (parsed.success) {
expect(parsed.data.scheduleId).toBe("sched-1");
expect(parsed.data.name).toBe("nightly backup");
expect(parsed.data.command).toBe("echo hello");
expect(parsed.data.cronExpression).toBe("* * * * *");
expect(parsed.data.enabled).toBe(true);
expect(parsed.data.script).toBe("echo hello");
}
});
it("does not require organizationId/scheduleType/serverId/appName/createdAt", () => {
const minimal = {
scheduleId: "sched-2",
name: "n",
command: "c",
cronExpression: "0 * * * *",
};
const parsed = updateScheduleSchema.safeParse(minimal);
expect(parsed.success).toBe(true);
});
});
+22 -21
View File
@@ -90,32 +90,12 @@ export const scheduleRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => {
const existingSchedule = await findScheduleById(input.scheduleId);
if (
IS_CLOUD &&
input.scheduleType &&
input.scheduleType !== existingSchedule.scheduleType
) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Changing scheduleType is not allowed in the cloud version.",
});
}
await assertHostScheduleAccess(
ctx,
existingSchedule.scheduleType,
existingSchedule.serverId,
existingSchedule.organizationId,
);
if (
input.scheduleType &&
input.scheduleType !== existingSchedule.scheduleType
) {
await assertHostScheduleAccess(
ctx,
input.scheduleType,
input.serverId ?? existingSchedule.serverId,
);
}
const serviceId =
existingSchedule.applicationId || existingSchedule.composeId;
@@ -169,6 +149,7 @@ export const scheduleRouter = createTRPCRouter({
ctx,
scheduleItem.scheduleType,
scheduleItem.serverId,
scheduleItem.organizationId,
);
const serviceId = scheduleItem.applicationId || scheduleItem.composeId;
@@ -308,6 +289,25 @@ export const scheduleRouter = createTRPCRouter({
});
}
}
if (schedule.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 read host-level schedules.",
});
}
if (schedule.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this schedule.",
});
}
}
}
return schedule;
}),
@@ -320,6 +320,7 @@ export const scheduleRouter = createTRPCRouter({
ctx,
scheduleItem.scheduleType,
scheduleItem.serverId,
scheduleItem.organizationId,
);
const serviceId = scheduleItem.applicationId || scheduleItem.composeId;
+15 -3
View File
@@ -83,6 +83,18 @@ export const createScheduleSchema = createInsertSchema(schedules, {
scheduleType: z.enum(["application", "compose", "server", "dokploy-server"]),
});
export const updateScheduleSchema = createScheduleSchema.extend({
scheduleId: z.string().min(1),
});
export const updateScheduleSchema = createScheduleSchema
.extend({ scheduleId: z.string().min(1) })
.omit({
// Ownership anchor — must never be caller-mutable on update. Allowing it
// would let a caller reassign a schedule into their own org (schedule theft).
organizationId: true,
// A schedule's type and target server are fixed at creation time. Allowing
// them to change on update enables re-typing a schedule onto a server the
// caller controls after passing the gate on the existing schedule's org.
scheduleType: true,
serverId: true,
// On-disk path identifier and creation timestamp are managed internally.
appName: true,
createdAt: true,
});
+20
View File
@@ -20,10 +20,18 @@ export type ScheduleExtended = Awaited<ReturnType<typeof findScheduleById>>;
// 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.
//
// `scheduleOrganizationId` is the owning organization of the targeted schedule
// row (for dokploy-server schedules, the `organizationId` column). It is used to
// enforce cross-organization isolation: callers may only act on dokploy-server
// schedules owned by their own active organization. It is left optional so the
// same helper can be used on `create` (where no schedule row exists yet — the
// org is assigned to the caller's active org by the router).
export const assertHostScheduleAccess = async (
ctx: { user: { id: string }; session: { activeOrganizationId: string } },
scheduleType: Schedule["scheduleType"] | null | undefined,
serverId: string | null | undefined,
scheduleOrganizationId?: string | null,
) => {
if (scheduleType !== "server" && scheduleType !== "dokploy-server") return;
@@ -54,6 +62,18 @@ export const assertHostScheduleAccess = async (
});
}
}
if (scheduleType === "dokploy-server") {
if (
scheduleOrganizationId &&
scheduleOrganizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this schedule.",
});
}
}
};
export const createSchedule = async (