diff --git a/Dockerfile b/Dockerfile index 00043b0c..c41df8c7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,4 @@ +# syntax=docker/dockerfile:1 FROM node:20.9-slim AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" diff --git a/Dockerfile.cloud b/Dockerfile.cloud index c1b66796..c234259d 100644 --- a/Dockerfile.cloud +++ b/Dockerfile.cloud @@ -1,3 +1,4 @@ +# syntax=docker/dockerfile:1 FROM node:20.9-slim AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" diff --git a/Dockerfile.monitoring b/Dockerfile.monitoring index 814625db..c54580ee 100644 --- a/Dockerfile.monitoring +++ b/Dockerfile.monitoring @@ -1,3 +1,4 @@ +# syntax=docker/dockerfile:1 # Build stage FROM golang:1.21-alpine3.19 AS builder diff --git a/Dockerfile.schedule b/Dockerfile.schedule index eba08f7b..70976523 100644 --- a/Dockerfile.schedule +++ b/Dockerfile.schedule @@ -1,3 +1,4 @@ +# syntax=docker/dockerfile:1 FROM node:20.9-slim AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" diff --git a/Dockerfile.server b/Dockerfile.server index 8fef5142..e911c878 100644 --- a/Dockerfile.server +++ b/Dockerfile.server @@ -1,3 +1,4 @@ +# syntax=docker/dockerfile:1 FROM node:20.9-slim AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..47633ab9 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,28 @@ +# Dokploy Security Policy + +At Dokploy, security is a top priority. We appreciate the help of security researchers and the community in identifying and reporting vulnerabilities. + +## How to Report a Vulnerability + +If you have discovered a security vulnerability in Dokploy, we ask that you report it responsibly by following these guidelines: + +1. **Contact us:** Send an email to [contact@dokploy.com](mailto:contact@dokploy.com). +2. **Provide clear details:** Include as much information as possible to help us understand and reproduce the vulnerability. This should include: + * A clear description of the vulnerability. + * Steps to reproduce the vulnerability. + * Any sample code, screenshots, or videos that might be helpful. + * The potential impact of the vulnerability. +3. **Do not make the vulnerability public:** Please refrain from publicly disclosing the vulnerability until we have had the opportunity to investigate and address it. This is crucial for protecting our users. +4. **Allow us time:** We will endeavor to acknowledge receipt of your report as soon as possible and keep you informed of our progress. The time to resolve the vulnerability may vary depending on its complexity and severity. + +## What We Expect From You + +* Do not access user data or systems beyond what is necessary to demonstrate the vulnerability. +* Do not perform denial-of-service (DoS) attacks, spamming, or social engineering. +* Do not modify or destroy data that does not belong to you. + +## Our Commitment + +We are committed to working with you quickly and responsibly to address any legitimate security vulnerability. + +Thank you for helping us keep Dokploy secure for everyone. diff --git a/apps/dokploy/components/dashboard/application/advanced/volumes/update-volume.tsx b/apps/dokploy/components/dashboard/application/advanced/volumes/update-volume.tsx index 8da09b58..d185b216 100644 --- a/apps/dokploy/components/dashboard/application/advanced/volumes/update-volume.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/volumes/update-volume.tsx @@ -247,7 +247,7 @@ export const UpdateVolume = ({ control={form.control} name="content" render={({ field }) => ( - + Content @@ -256,7 +256,7 @@ export const UpdateVolume = ({ placeholder={`NODE_ENV=production PORT=3000 `} - className="h-96 font-mono" + className="h-96 font-mono w-full" {...field} /> diff --git a/apps/dokploy/components/dashboard/application/domains/show-domains.tsx b/apps/dokploy/components/dashboard/application/domains/show-domains.tsx index fe637353..7bb58dfb 100644 --- a/apps/dokploy/components/dashboard/application/domains/show-domains.tsx +++ b/apps/dokploy/components/dashboard/application/domains/show-domains.tsx @@ -39,6 +39,7 @@ export type ValidationState = { error?: string; resolvedIp?: string; message?: string; + cdnProvider?: string; }; export type ValidationStates = Record; @@ -119,6 +120,7 @@ export const ShowDomains = ({ id, type }: Props) => { isValid: result.isValid, error: result.error, resolvedIp: result.resolvedIp, + cdnProvider: result.cdnProvider, message: result.error && result.isValid ? result.error : undefined, }, })); @@ -354,8 +356,9 @@ export const ShowDomains = ({ id, type }: Props) => { ) : validationState?.isValid ? ( <> - {validationState.message - ? "Behind Cloudflare" + {validationState.message && + validationState.cdnProvider + ? `Behind ${validationState.cdnProvider}` : "DNS Valid"} ) : validationState?.error ? ( diff --git a/apps/dokploy/components/dashboard/application/environment/show.tsx b/apps/dokploy/components/dashboard/application/environment/show.tsx index 6f504959..35ddc51b 100644 --- a/apps/dokploy/components/dashboard/application/environment/show.tsx +++ b/apps/dokploy/components/dashboard/application/environment/show.tsx @@ -49,7 +49,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => { currentBuildArgs !== (data?.buildArgs || ""); useEffect(() => { - if (data) { + if (data && !hasChanges) { form.reset({ env: data.env || "", buildArgs: data.buildArgs || "", diff --git a/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx b/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx index 50f0f4ab..63ff189d 100644 --- a/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx +++ b/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx @@ -44,8 +44,10 @@ export const ComposeFileEditor = ({ composeId }: Props) => { resolver: zodResolver(AddComposeFile), }); + const composeFile = form.watch("composeFile"); + useEffect(() => { - if (data) { + if (data && !composeFile) { form.reset({ composeFile: data.composeFile || "", }); diff --git a/apps/dokploy/components/dashboard/projects/handle-project.tsx b/apps/dokploy/components/dashboard/projects/handle-project.tsx index ddc1303e..01d66fba 100644 --- a/apps/dokploy/components/dashboard/projects/handle-project.tsx +++ b/apps/dokploy/components/dashboard/projects/handle-project.tsx @@ -38,7 +38,7 @@ const AddProjectSchema = z.object({ (name) => { const trimmedName = name.trim(); const validNameRegex = - /^[\p{L}\p{N}_-][\p{L}\p{N}\s_-]*[\p{L}\p{N}_-]$/u; + /^[\p{L}\p{N}_-][\p{L}\p{N}\s_.-]*[\p{L}\p{N}_-]$/u; return validNameRegex.test(trimmedName); }, { diff --git a/apps/dokploy/components/dashboard/settings/certificates/utils.ts b/apps/dokploy/components/dashboard/settings/certificates/utils.ts index 80f332d8..716087db 100644 --- a/apps/dokploy/components/dashboard/settings/certificates/utils.ts +++ b/apps/dokploy/components/dashboard/settings/certificates/utils.ts @@ -1,80 +1,89 @@ export const extractExpirationDate = (certData: string): Date | null => { try { - const match = certData.match( - /-----BEGIN CERTIFICATE-----\s*([^-]+)\s*-----END CERTIFICATE-----/, - ); - if (!match?.[1]) return null; - - const base64Cert = match[1].replace(/\s/g, ""); - const binaryStr = window.atob(base64Cert); - const bytes = new Uint8Array(binaryStr.length); - - for (let i = 0; i < binaryStr.length; i++) { - bytes[i] = binaryStr.charCodeAt(i); + // Decode PEM base64 to DER binary + const b64 = certData.replace(/-----[^-]+-----/g, "").replace(/\s+/g, ""); + const binStr = atob(b64); + const der = new Uint8Array(binStr.length); + for (let i = 0; i < binStr.length; i++) { + der[i] = binStr.charCodeAt(i); } - // ASN.1 tag for UTCTime is 0x17, GeneralizedTime is 0x18 - // We need to find the second occurrence of either tag as it's the "not after" (expiration) date - let dateFound = false; - for (let i = 0; i < bytes.length - 2; i++) { - // Look for sequence containing validity period (0x30) - if (bytes[i] === 0x30) { - // Check next bytes for UTCTime or GeneralizedTime - let j = i + 1; - while (j < bytes.length - 2) { - if (bytes[j] === 0x17 || bytes[j] === 0x18) { - const dateType = bytes[j]; - const dateLength = bytes[j + 1]; - if (typeof dateLength === "undefined") break; + let offset = 0; - if (!dateFound) { - // Skip "not before" date - dateFound = true; - j += dateLength + 2; - continue; - } - - // Found "not after" date - let dateStr = ""; - for (let k = 0; k < dateLength; k++) { - const charCode = bytes[j + 2 + k]; - if (typeof charCode === "undefined") continue; - dateStr += String.fromCharCode(charCode); - } - - if (dateType === 0x17) { - // UTCTime (YYMMDDhhmmssZ) - const year = Number.parseInt(dateStr.slice(0, 2)); - const fullYear = year >= 50 ? 1900 + year : 2000 + year; - return new Date( - Date.UTC( - fullYear, - Number.parseInt(dateStr.slice(2, 4)) - 1, - Number.parseInt(dateStr.slice(4, 6)), - Number.parseInt(dateStr.slice(6, 8)), - Number.parseInt(dateStr.slice(8, 10)), - Number.parseInt(dateStr.slice(10, 12)), - ), - ); - } - - // GeneralizedTime (YYYYMMDDhhmmssZ) - return new Date( - Date.UTC( - Number.parseInt(dateStr.slice(0, 4)), - Number.parseInt(dateStr.slice(4, 6)) - 1, - Number.parseInt(dateStr.slice(6, 8)), - Number.parseInt(dateStr.slice(8, 10)), - Number.parseInt(dateStr.slice(10, 12)), - Number.parseInt(dateStr.slice(12, 14)), - ), - ); - } - j++; + // Helper: read ASN.1 length field + function readLength(pos: number): { length: number; offset: number } { + let len = der[pos++]; + if (len & 0x80) { + const bytes = len & 0x7f; + len = 0; + for (let i = 0; i < bytes; i++) { + len = (len << 8) + der[pos++]; } } + return { length: len, offset: pos }; } - return null; + + // Skip the outer certificate sequence + if (der[offset++] !== 0x30) throw new Error("Expected sequence"); + ({ offset } = readLength(offset)); + + // Skip tbsCertificate sequence + if (der[offset++] !== 0x30) throw new Error("Expected tbsCertificate"); + ({ offset } = readLength(offset)); + + // Check for optional version field (context-specific tag [0]) + if (der[offset] === 0xa0) { + offset++; + const versionLen = readLength(offset); + offset = versionLen.offset + versionLen.length; + } + + // Skip serialNumber, signature, issuer + for (let i = 0; i < 3; i++) { + if (der[offset] !== 0x30 && der[offset] !== 0x02) + throw new Error("Unexpected structure"); + offset++; + const fieldLen = readLength(offset); + offset = fieldLen.offset + fieldLen.length; + } + + // Validity sequence (notBefore and notAfter) + if (der[offset++] !== 0x30) throw new Error("Expected validity sequence"); + const validityLen = readLength(offset); + offset = validityLen.offset; + + // notBefore + offset++; + const notBeforeLen = readLength(offset); + offset = notBeforeLen.offset + notBeforeLen.length; + + // notAfter + offset++; + const notAfterLen = readLength(offset); + const notAfterStr = new TextDecoder().decode( + der.slice(notAfterLen.offset, notAfterLen.offset + notAfterLen.length), + ); + + // Parse GeneralizedTime (15 chars) or UTCTime (13 chars) + function parseTime(str: string): Date { + if (str.length === 13) { + // UTCTime YYMMDDhhmmssZ + const year = parseInt(str.slice(0, 2), 10); + const fullYear = year < 50 ? 2000 + year : 1900 + year; + return new Date( + `${fullYear}-${str.slice(2, 4)}-${str.slice(4, 6)}T${str.slice(6, 8)}:${str.slice(8, 10)}:${str.slice(10, 12)}Z`, + ); + } else if (str.length === 15) { + // GeneralizedTime YYYYMMDDhhmmssZ + return new Date( + `${str.slice(0, 4)}-${str.slice(4, 6)}-${str.slice(6, 8)}T${str.slice(8, 10)}:${str.slice(10, 12)}:${str.slice(12, 14)}Z`, + ); + } else { + throw new Error("Invalid ASN.1 time format"); + } + } + + return parseTime(notAfterStr); } catch (error) { console.error("Error parsing certificate:", error); return null; diff --git a/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx b/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx index d05409fb..24e8f34e 100644 --- a/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx +++ b/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx @@ -41,6 +41,7 @@ const addInvitation = z.object({ .min(1, "Email is required") .email({ message: "Invalid email" }), role: z.enum(["member", "admin"]), + notificationId: z.string().optional(), }); type AddInvitation = z.infer; @@ -49,6 +50,10 @@ export const AddInvitation = () => { const [open, setOpen] = useState(false); const utils = api.useUtils(); const [isLoading, setIsLoading] = useState(false); + const { data: isCloud } = api.settings.isCloud.useQuery(); + const { data: emailProviders } = + api.notification.getEmailProviders.useQuery(); + const { mutateAsync: sendInvitation } = api.user.sendInvitation.useMutation(); const [error, setError] = useState(null); const { data: activeOrganization } = authClient.useActiveOrganization(); @@ -56,6 +61,7 @@ export const AddInvitation = () => { defaultValues: { email: "", role: "member", + notificationId: "", }, resolver: zodResolver(addInvitation), }); @@ -74,7 +80,20 @@ export const AddInvitation = () => { if (result.error) { setError(result.error.message || ""); } else { - toast.success("Invitation created"); + if (!isCloud && data.notificationId) { + await sendInvitation({ + invitationId: result.data.id, + notificationId: data.notificationId || "", + }) + .then(() => { + toast.success("Invitation created and email sent"); + }) + .catch((error: any) => { + toast.error(error.message); + }); + } else { + toast.success("Invitation created"); + } setError(null); setOpen(false); } @@ -149,6 +168,47 @@ export const AddInvitation = () => { ); }} /> + + {!isCloud && ( + { + return ( + + Email Provider + + + Select the email provider to send the invitation + + + + ); + }} + /> + )}