Compare commits

..

6 Commits

Author SHA1 Message Date
Mauricio Siu
3b102fac56 fix(ui): organization menu clipped when sidebar is collapsed
The organization switcher's DropdownMenuContent inherited its width from
the collapsed trigger (~40px) via the base primitive's
w-(--radix-dropdown-menu-trigger-width), clamping the menu to the
min-w-32 (128px) floor. This cut off the org name and action buttons in
icon mode.

Set an explicit w-64 so the menu fits its content regardless of the
trigger width, matching the pattern already used by the notification
dropdown in the same file.

Fixes #4840
2026-07-20 18:03:28 -06:00
Mauricio Siu
b2ade17487 Merge pull request #4874 from Dokploy/fix/idor-server-remove
fix(security): cross-org authorization bypass in server.remove
2026-07-20 17:40:18 -06:00
Mauricio Siu
ffe62bca0e Merge pull request #4875 from Dokploy/fix/cmdi-registry-test-login
fix(security): command injection in registry.testRegistry / testRegistryById
2026-07-20 17:40:00 -06:00
Mauricio Siu
d3f522b7a6 fix(security): command injection in registry.testRegistry/testRegistryById remote path
The remote (execAsyncRemote) path built `echo ${password} | docker ${args.join(" ")}`
with the password, registryUrl and username interpolated unescaped, so a password
like `pw; whoami` ran arbitrary commands as root on the target server. Reuse
safeDockerLoginCommand (already used by create/update), which shell-escapes each
field and feeds the password via --password-stdin. The local argv+stdin path was
already safe.
2026-07-20 17:17:45 -06:00
Mauricio Siu
4aee66b2d1 fix(security): enforce organization ownership on server.remove
server.remove deleted a server (and its deployment rows) by caller-supplied
serverId without checking it belongs to the active organization, unlike server.one
and server.update. An owner/admin of org A could delete org B's server registration.
Resolve and compare the server's organizationId before the active-services guard,
so cross-org callers are rejected without leaking existence.
2026-07-20 17:14:35 -06:00
Mauricio Siu
d02f34f9d4 Merge pull request #4873 from Dokploy/fix/cmdi-quote-sweep
fix(security): escape user-controlled values across command-injection sinks (quote sweep)
2026-07-20 17:05:16 -06:00
6 changed files with 28 additions and 14 deletions

View File

@@ -648,7 +648,7 @@ function SidebarLogo() {
</SidebarMenuButton> </SidebarMenuButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
className="rounded-lg max-h-[min(70vh,28rem)] flex flex-col" className="w-64 rounded-lg max-h-[min(70vh,28rem)] flex flex-col"
align="start" align="start"
side={isMobile ? "bottom" : "right"} side={isMobile ? "bottom" : "right"}
sideOffset={4} sideOffset={4}

View File

@@ -5,6 +5,7 @@ import {
findRegistryById, findRegistryById,
IS_CLOUD, IS_CLOUD,
removeRegistry, removeRegistry,
safeDockerLoginCommand,
updateRegistry, updateRegistry,
} from "@dokploy/server"; } from "@dokploy/server";
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
@@ -122,7 +123,11 @@ export const registryRouter = createTRPCRouter({
if (input.serverId && input.serverId !== "none") { if (input.serverId && input.serverId !== "none") {
await execAsyncRemote( await execAsyncRemote(
input.serverId, input.serverId,
`echo ${input.password} | docker ${args.join(" ")}`, safeDockerLoginCommand(
input.registryUrl,
input.username,
input.password,
),
); );
} else { } else {
await execFileAsync("docker", args, { await execFileAsync("docker", args, {
@@ -182,7 +187,11 @@ export const registryRouter = createTRPCRouter({
if (input.serverId && input.serverId !== "none") { if (input.serverId && input.serverId !== "none") {
await execAsyncRemote( await execAsyncRemote(
input.serverId, input.serverId,
`echo ${registryData.password} | docker ${args.join(" ")}`, safeDockerLoginCommand(
registryData.registryUrl,
registryData.username,
registryData.password,
),
); );
} else { } else {
await execFileAsync("docker", args, { await execFileAsync("docker", args, {

View File

@@ -413,6 +413,14 @@ export const serverRouter = createTRPCRouter({
.input(apiRemoveServer) .input(apiRemoveServer)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
const currentServer = await findServerById(input.serverId);
if (currentServer.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to delete this server",
});
}
const activeServers = await haveActiveServices(input.serverId); const activeServers = await haveActiveServices(input.serverId);
if (activeServers) { if (activeServers) {
@@ -421,7 +429,6 @@ export const serverRouter = createTRPCRouter({
message: "Server has active services, please delete them first", message: "Server has active services, please delete them first",
}); });
} }
const currentServer = await findServerById(input.serverId);
await audit(ctx, { await audit(ctx, {
action: "delete", action: "delete",
resourceType: "server", resourceType: "server",

View File

@@ -45,7 +45,7 @@ export const apiCreateCertificate = createInsertSchema(certificates, {
privateKey: z.string().min(1), privateKey: z.string().min(1),
autoRenew: z.boolean().optional(), autoRenew: z.boolean().optional(),
serverId: z.string().optional(), serverId: z.string().optional(),
}).omit({ certificatePath: true }); });
export const apiFindCertificate = z.object({ export const apiFindCertificate = z.object({
certificateId: z.string().min(1), certificateId: z.string().min(1),

View File

@@ -81,7 +81,7 @@ export const schedulesRelations = relations(schedules, ({ one, many }) => ({
export const createScheduleSchema = createInsertSchema(schedules, { export const createScheduleSchema = createInsertSchema(schedules, {
scheduleType: z.enum(["application", "compose", "server", "dokploy-server"]), scheduleType: z.enum(["application", "compose", "server", "dokploy-server"]),
}).omit({ appName: true }); });
export const updateScheduleSchema = createScheduleSchema.extend({ export const updateScheduleSchema = createScheduleSchema.extend({
scheduleId: z.string().min(1), scheduleId: z.string().min(1),

View File

@@ -1,7 +1,6 @@
import path from "node:path"; 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 { quote } from "shell-quote";
import type { z } from "zod"; import type { z } from "zod";
import { IS_CLOUD, paths } from "../constants"; import { IS_CLOUD, paths } from "../constants";
import { db } from "../db"; import { db } from "../db";
@@ -143,7 +142,7 @@ export const deleteSchedule = async (scheduleId: string) => {
const { SCHEDULES_PATH } = paths(!!serverId); const { SCHEDULES_PATH } = paths(!!serverId);
const fullPath = path.join(SCHEDULES_PATH, schedule?.appName || ""); const fullPath = path.join(SCHEDULES_PATH, schedule?.appName || "");
const command = `rm -rf ${quote([fullPath])}`; const command = `rm -rf ${fullPath}`;
if (serverId) { if (serverId) {
await execAsyncRemote(serverId, command); await execAsyncRemote(serverId, command);
} else { } else {
@@ -199,13 +198,12 @@ const handleScript = async (schedule: Schedule) => {
${schedule?.script || ""}`; ${schedule?.script || ""}`;
const encodedContent = encodeBase64(scriptWithPid); const encodedContent = encodeBase64(scriptWithPid);
const scriptPath = `${fullPath}/script.sh`;
const script = ` const script = `
mkdir -p ${quote([fullPath])} mkdir -p ${fullPath}
rm -f ${quote([scriptPath])} rm -f ${fullPath}/script.sh
touch ${quote([scriptPath])} touch ${fullPath}/script.sh
chmod +x ${quote([scriptPath])} chmod +x ${fullPath}/script.sh
echo "${encodedContent}" | base64 -d > ${quote([scriptPath])} echo "${encodedContent}" | base64 -d > ${fullPath}/script.sh
`; `;
if (schedule?.scheduleType === "dokploy-server") { if (schedule?.scheduleType === "dokploy-server") {