Compare commits

..

2 Commits

Author SHA1 Message Date
Mauricio Siu
fbf2ff73c2 fix(security): stop user-supplied certificatePath in create schema
Same drizzle-zod $defaultFn-as-optional leak as schedule appName: certificatePath
(notNull + $defaultFn) was overridable, enabling path traversal out of the certs
dir even with the escaped shell paths. Omit it from apiCreateCertificate so it is
always server-generated (completes GHSA-q9qw).
2026-07-20 17:08:32 -06:00
Mauricio Siu
6943a395fe fix(security): stop user-supplied schedule appName and escape its shell paths
drizzle-zod exposed appName (notNull + $defaultFn) as optional in the insert
schema, letting callers override it; it then flowed unquoted into rm -rf/mkdir
paths. Omit appName from createScheduleSchema and quote() the schedule fullPath
sinks (completes GHSA-fgh8 VULN-015 alongside the mount/patch fixes).
2026-07-20 17:07:32 -06:00
17 changed files with 31 additions and 318 deletions

View File

@@ -84,19 +84,19 @@ export const ComposeActions = ({ composeId }: Props) => {
)}
{canDeploy && (
<DialogAction
title="Rebuild Compose"
description="Are you sure you want to rebuild this compose?"
title="Reload Compose"
description="Are you sure you want to reload this compose?"
type="default"
onClick={async () => {
await redeploy({
composeId: composeId,
})
.then(() => {
toast.success("Compose rebuilt successfully");
toast.success("Compose reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error rebuilding compose");
toast.error("Error reloading compose");
});
}}
>
@@ -109,14 +109,12 @@ export const ComposeActions = ({ composeId }: Props) => {
<TooltipTrigger asChild>
<div className="flex items-center">
<RefreshCcw className="size-4 mr-1" />
Rebuild
Reload
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-60">
<p>
Rebuilds the compose without downloading the source code
</p>
<p>Reload the compose without rebuilding it</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>

View File

@@ -13,14 +13,10 @@ import {
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { HandleAi } from "./handle-ai";
import { HandleAiProviders } from "./handle-ai-providers";
export const AiForm = () => {
const { data: aiConfigs, refetch, isPending } = api.ai.getAll.useQuery();
const { mutateAsync, isPending: isRemoving } = api.ai.delete.useMutation();
const { data: currentUser } = api.user.get.useQuery();
const isOrgAdmin =
currentUser?.role === "owner" || currentUser?.role === "admin";
return (
<div className="w-full">
@@ -34,10 +30,7 @@ export const AiForm = () => {
</CardTitle>
<CardDescription>Manage your AI configurations</CardDescription>
</div>
<div className="flex flex-row gap-2">
{isOrgAdmin && <HandleAiProviders />}
{aiConfigs && aiConfigs?.length > 0 && <HandleAi />}
</div>
{aiConfigs && aiConfigs?.length > 0 && <HandleAi />}
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
{isPending ? (

View File

@@ -1,158 +0,0 @@
"use client";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PlusIcon, ServerIcon, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
const Schema = z.object({
providers: z.array(
z.object({
name: z.string().min(1, { message: "Name is required" }),
apiUrl: z.string().url({ message: "Please enter a valid URL" }),
}),
),
});
type Schema = z.infer<typeof Schema>;
export const HandleAiProviders = () => {
const utils = api.useUtils();
const [open, setOpen] = useState(false);
const { data: providers } = api.ai.getCustomProviders.useQuery();
const { mutateAsync, isPending } = api.ai.saveCustomProviders.useMutation();
const form = useForm<Schema>({
resolver: zodResolver(Schema),
defaultValues: {
providers: [],
},
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "providers",
});
useEffect(() => {
if (open) {
form.reset({ providers: providers ?? [] });
}
}, [open, providers, form]);
const onSubmit = async (data: Schema) => {
try {
await mutateAsync({ providers: data.providers });
await utils.ai.getCustomProviders.invalidate();
toast.success("Custom providers saved successfully");
setOpen(false);
} catch (error) {
toast.error("Failed to save custom providers", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="cursor-pointer space-x-3">
<ServerIcon className="h-4 w-4" />
Custom Presets
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>Custom AI Providers</DialogTitle>
<DialogDescription>
Define your own AI providers, like an internal LLM platform. When at
least one is defined, only these providers can be used in AI
configurations.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{fields.length === 0 && (
<p className="text-sm text-muted-foreground">
No custom providers defined. The built-in provider list will be
used.
</p>
)}
{fields.map((fieldItem, index) => (
<div key={fieldItem.id} className="flex gap-2 items-start">
<FormField
control={form.control}
name={`providers.${index}.name`}
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input placeholder="Internal LLM" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`providers.${index}.apiUrl`}
render={({ field }) => (
<FormItem className="flex-[2]">
<FormControl>
<Input
placeholder="https://llm.internal.company/v1"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
onClick={() => remove(index)}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</div>
))}
<div className="flex justify-between">
<Button
type="button"
variant="outline"
onClick={() => append({ name: "", apiUrl: "" })}
>
<PlusIcon className="h-4 w-4" />
Add Provider
</Button>
<Button type="submit" isLoading={isPending}>
Save
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -99,11 +99,6 @@ export const HandleAi = ({ aiId }: Props) => {
enabled: !!aiId,
},
);
const { data: customProviders } = api.ai.getCustomProviders.useQuery();
const hasCustomProviders = (customProviders?.length ?? 0) > 0;
const providerOptions: { name: string; apiUrl: string }[] = hasCustomProviders
? (customProviders ?? [])
: [...AI_PROVIDERS];
const { mutateAsync, isPending } = aiId
? api.ai.update.useMutation()
: api.ai.create.useMutation();
@@ -215,9 +210,7 @@ export const HandleAi = ({ aiId }: Props) => {
<FormLabel>Provider</FormLabel>
<Select
onValueChange={(value) => {
const provider = providerOptions.find(
(p) => p.apiUrl === value,
);
const provider = AI_PROVIDERS.find((p) => p.apiUrl === value);
if (provider) {
form.setValue("name", provider.name);
form.setValue("apiUrl", provider.apiUrl);
@@ -229,20 +222,15 @@ export const HandleAi = ({ aiId }: Props) => {
<SelectValue placeholder="Select a provider preset..." />
</SelectTrigger>
<SelectContent>
{providerOptions.map((provider) => (
<SelectItem
key={`${provider.name}-${provider.apiUrl}`}
value={provider.apiUrl}
>
{AI_PROVIDERS.map((provider) => (
<SelectItem key={provider.apiUrl} value={provider.apiUrl}>
{provider.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[0.8rem] text-muted-foreground">
{hasCustomProviders
? "Select one of the providers defined by your organization"
: "Quick-fill provider name and URL, or configure manually below"}
Quick-fill provider name and URL, or configure manually below
</p>
</div>
@@ -272,7 +260,6 @@ export const HandleAi = ({ aiId }: Props) => {
<FormControl>
<Input
placeholder="https://api.openai.com/v1"
disabled={hasCustomProviders}
{...field}
onChange={(e) => {
field.onChange(e);
@@ -284,9 +271,7 @@ export const HandleAi = ({ aiId }: Props) => {
/>
</FormControl>
<FormDescription>
{hasCustomProviders
? "The API URL is defined by your organization's providers"
: "The base URL for your AI provider's API"}
The base URL for your AI provider's API
</FormDescription>
<FormMessage />
</FormItem>

View File

@@ -100,7 +100,7 @@ export const Enable2FA = () => {
});
if (result.error) {
if (result.error.code === "INVALID_CODE") {
if (result.error.code === "INVALID_TWO_FACTOR_AUTHENTICATION") {
toast.error("Invalid verification code");
return;
}

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.29.13",
"version": "v0.29.12",
"private": true,
"license": "Apache-2.0",
"type": "module",

View File

@@ -1,7 +1,6 @@
import { IS_CLOUD } from "@dokploy/server/constants";
import {
apiCreateAi,
apiSaveAiCustomProviders,
apiUpdateAi,
deploySuggestionSchema,
} from "@dokploy/server/db/schema/ai";
@@ -14,9 +13,7 @@ import {
deleteAiSettings,
getAiSettingById,
getAiSettingsByOrganizationId,
getCustomAiProviders,
saveAiSettings,
saveCustomAiProviders,
suggestVariants,
} from "@dokploy/server/services/ai";
import { createComposeByTemplate } from "@dokploy/server/services/compose";
@@ -203,19 +200,6 @@ export const aiRouter = createTRPCRouter({
return await deleteAiSettings(input.aiId);
}),
getCustomProviders: protectedProcedure.query(async ({ ctx }) => {
return await getCustomAiProviders(ctx.session.activeOrganizationId);
}),
saveCustomProviders: adminProcedure
.input(apiSaveAiCustomProviders)
.mutation(async ({ ctx, input }) => {
return await saveCustomAiProviders(
ctx.session.activeOrganizationId,
input.providers,
);
}),
getEnabledProviders: protectedProcedure.query(async ({ ctx }) => {
const settings = await getAiSettingsByOrganizationId(
ctx.session.activeOrganizationId,

View File

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

View File

@@ -413,14 +413,6 @@ export const serverRouter = createTRPCRouter({
.input(apiRemoveServer)
.mutation(async ({ input, ctx }) => {
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);
if (activeServers) {
@@ -429,6 +421,7 @@ export const serverRouter = createTRPCRouter({
message: "Server has active services, please delete them first",
});
}
const currentServer = await findServerById(input.serverId);
await audit(ctx, {
action: "delete",
resourceType: "server",

View File

@@ -54,15 +54,6 @@ export const apiUpdateAi = createSchema
})
.omit({ organizationId: true });
export const aiCustomProviderSchema = z.object({
name: z.string().min(1, { message: "Name is required" }),
apiUrl: z.string().url({ message: "Please enter a valid URL" }),
});
export const apiSaveAiCustomProviders = z.object({
providers: z.array(aiCustomProviderSchema),
});
export const deploySuggestionSchema = z.object({
environmentId: z.string().min(1),
id: z.string().min(1),

View File

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

View File

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

View File

@@ -419,7 +419,7 @@ const createBetterAuth = () =>
enableMetadata: true,
references: "user",
}),
sso({ trustEmailVerified: true }),
sso(),
scim({
beforeSCIMTokenGenerated: async ({ user }) => {
const dbUser = await db.query.user.findFirst({

View File

@@ -1,6 +1,5 @@
import { db } from "@dokploy/server/db";
import { ai, organization } from "@dokploy/server/db/schema";
import { aiCustomProviderSchema } from "@dokploy/server/db/schema/ai";
import { ai } from "@dokploy/server/db/schema";
import { selectAIProvider } from "@dokploy/server/utils/ai/select-ai-provider";
import { TRPCError } from "@trpc/server";
import { generateText, Output } from "ai";
@@ -49,74 +48,9 @@ export const getAiSettingById = async (aiId: string) => {
return aiSetting;
};
type AiCustomProvider = z.infer<typeof aiCustomProviderSchema>;
const parseOrgMetadata = (metadata: string | null) => {
try {
const parsed = JSON.parse(metadata || "{}");
return typeof parsed === "object" && parsed !== null
? (parsed as Record<string, unknown>)
: {};
} catch {
return {};
}
};
export const getCustomAiProviders = async (organizationId: string) => {
const org = await db.query.organization.findFirst({
where: eq(organization.id, organizationId),
});
const metadata = parseOrgMetadata(org?.metadata ?? null);
const result = z
.array(aiCustomProviderSchema)
.safeParse(metadata.aiProviders);
return result.success ? result.data : [];
};
export const saveCustomAiProviders = async (
organizationId: string,
providers: AiCustomProvider[],
) => {
const org = await db.query.organization.findFirst({
where: eq(organization.id, organizationId),
});
if (!org) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Organization not found",
});
}
const metadata = parseOrgMetadata(org.metadata);
metadata.aiProviders = providers;
await db
.update(organization)
.set({ metadata: JSON.stringify(metadata) })
.where(eq(organization.id, organizationId));
return providers;
};
const normalizeApiUrl = (url: string) => url.trim().replace(/\/+$/, "");
export const saveAiSettings = async (organizationId: string, settings: any) => {
const aiId = settings.aiId;
if (settings.apiUrl) {
const customProviders = await getCustomAiProviders(organizationId);
if (customProviders.length > 0) {
const isAllowed = customProviders.some(
(provider) =>
normalizeApiUrl(provider.apiUrl) === normalizeApiUrl(settings.apiUrl),
);
if (!isAllowed) {
throw new TRPCError({
code: "FORBIDDEN",
message:
"This API URL is not in your organization's allowed AI providers",
});
}
}
}
return db
.insert(ai)
.values({

View File

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

View File

@@ -430,7 +430,7 @@ export const createOrganizationUserWithCredentials = async ({
.insert(user)
.values({
email: normalizedEmail,
emailVerified: true,
emailVerified: false,
updatedAt: now,
})
.returning({