diff --git a/apps/dokploy/__test__/utils/hostname-validation.test.ts b/apps/dokploy/__test__/utils/hostname-validation.test.ts new file mode 100644 index 000000000..c0e734282 --- /dev/null +++ b/apps/dokploy/__test__/utils/hostname-validation.test.ts @@ -0,0 +1,46 @@ +import { VALID_HOSTNAME_REGEX } from "@dokploy/server"; +import { describe, expect, it } from "vitest"; + +describe("VALID_HOSTNAME_REGEX", () => { + it.each([ + "example.com", + "sub.example.com", + "bbn-client.example.com", + "a.b.c.example.co", + "xn--80ak6aa92e.com", + "123.example.com", + ])("accepts valid hostname %s", (host) => { + expect(VALID_HOSTNAME_REGEX.test(host)).toBe(true); + }); + + it.each([ + "bbn_client.example.com", + "-example.com", + "example-.com", + "example", + "exa mple.com", + "example..com", + "", + `a${"a".repeat(63)}.com`, + ])("rejects invalid hostname %s", (host) => { + expect(VALID_HOSTNAME_REGEX.test(host)).toBe(false); + }); + + // IDNs (Cyrillic, German umlauts, etc.) must be submitted in their + // ACME/punycode form ("xn--...") — that's what Let's Encrypt issues + // certificates for, so raw Unicode labels are rejected here. + it.each(["пример.рф", "bücher.de", "日本語.jp"])( + "rejects raw unicode IDN %s", + (host) => { + expect(VALID_HOSTNAME_REGEX.test(host)).toBe(false); + }, + ); + + it.each([ + "xn--e1afmkfd.xn--p1ai", // punycode for пример.рф + "xn--bcher-kva.de", // punycode for bücher.de + "xn--wgv71a119e.jp", // punycode for 日本語.jp + ])("accepts punycode-encoded IDN %s", (host) => { + expect(VALID_HOSTNAME_REGEX.test(host)).toBe(true); + }); +}); diff --git a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx index d7612701f..be8901921 100644 --- a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx +++ b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx @@ -1,3 +1,7 @@ +import { + INVALID_HOSTNAME_MESSAGE, + VALID_HOSTNAME_REGEX, +} from "@dokploy/server/utils/hostname-validation"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { DatabaseZap, Dices, RefreshCw, X } from "lucide-react"; import Link from "next/link"; @@ -53,7 +57,10 @@ export const domain = z .refine((val) => val === val.trim(), { message: "Domain name cannot have leading or trailing spaces", }) - .transform((val) => val.trim()), + .transform((val) => val.trim()) + .refine((val) => VALID_HOSTNAME_REGEX.test(val), { + message: INVALID_HOSTNAME_MESSAGE, + }), path: z.string().min(1).optional(), internalPath: z.string().optional(), stripPath: z.boolean().optional(), diff --git a/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/create-server.tsx b/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/create-server.tsx index 41aac01d3..ef9cc6de3 100644 --- a/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/create-server.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/create-server.tsx @@ -195,9 +195,7 @@ export const CreateServer = ({ stepper }: Props) => { {sshKey.name} ))} - - Registries ({sshKeys?.length}) - + SSH Keys ({sshKeys?.length}) diff --git a/apps/dokploy/components/dashboard/settings/web-domain.tsx b/apps/dokploy/components/dashboard/settings/web-domain.tsx index 4c88678a2..4f543a1be 100644 --- a/apps/dokploy/components/dashboard/settings/web-domain.tsx +++ b/apps/dokploy/components/dashboard/settings/web-domain.tsx @@ -1,3 +1,7 @@ +import { + INVALID_HOSTNAME_MESSAGE, + VALID_HOSTNAME_REGEX, +} from "@dokploy/server/utils/hostname-validation"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { GlobeIcon } from "lucide-react"; import { useEffect } from "react"; @@ -35,7 +39,13 @@ import { api } from "@/utils/api"; const addServerDomain = z .object({ - domain: z.string().trim().toLowerCase(), + domain: z + .string() + .trim() + .toLowerCase() + .refine((val) => VALID_HOSTNAME_REGEX.test(val), { + message: INVALID_HOSTNAME_MESSAGE, + }), letsEncryptEmail: z.string(), https: z.boolean().optional(), certificateType: z.enum(["letsencrypt", "none", "custom"]), diff --git a/apps/dokploy/server/db/validations/domain.ts b/apps/dokploy/server/db/validations/domain.ts index 35de518c0..033a29303 100644 --- a/apps/dokploy/server/db/validations/domain.ts +++ b/apps/dokploy/server/db/validations/domain.ts @@ -1,3 +1,7 @@ +import { + INVALID_HOSTNAME_MESSAGE, + VALID_HOSTNAME_REGEX, +} from "@dokploy/server/utils/hostname-validation"; import { z } from "zod"; export const domain = z @@ -8,7 +12,10 @@ export const domain = z .refine((val) => val === val.trim(), { message: "Domain name cannot have leading or trailing spaces", }) - .transform((val) => val.trim()), + .transform((val) => val.trim()) + .refine((val) => VALID_HOSTNAME_REGEX.test(val), { + message: INVALID_HOSTNAME_MESSAGE, + }), path: z.string().min(1).optional(), port: z .number() @@ -45,7 +52,10 @@ export const domainCompose = z .refine((val) => val === val.trim(), { message: "Domain name cannot have leading or trailing spaces", }) - .transform((val) => val.trim()), + .transform((val) => val.trim()) + .refine((val) => VALID_HOSTNAME_REGEX.test(val), { + message: INVALID_HOSTNAME_MESSAGE, + }), path: z.string().min(1).optional(), port: z .number() diff --git a/packages/server/src/db/validations/domain.ts b/packages/server/src/db/validations/domain.ts index 62447bee7..059e3cae2 100644 --- a/packages/server/src/db/validations/domain.ts +++ b/packages/server/src/db/validations/domain.ts @@ -1,4 +1,8 @@ import { z } from "zod"; +import { + INVALID_HOSTNAME_MESSAGE, + VALID_HOSTNAME_REGEX, +} from "../../utils/hostname-validation"; export const domain = z .object({ @@ -8,7 +12,10 @@ export const domain = z .refine((val) => val === val.trim(), { message: "Domain name cannot have leading or trailing spaces", }) - .transform((val) => val.trim()), + .transform((val) => val.trim()) + .refine((val) => VALID_HOSTNAME_REGEX.test(val), { + message: INVALID_HOSTNAME_MESSAGE, + }), path: z.string().min(1).optional(), internalPath: z.string().optional(), stripPath: z.boolean().optional(), @@ -71,7 +78,10 @@ export const domainCompose = z .refine((val) => val === val.trim(), { message: "Domain name cannot have leading or trailing spaces", }) - .transform((val) => val.trim()), + .transform((val) => val.trim()) + .refine((val) => VALID_HOSTNAME_REGEX.test(val), { + message: INVALID_HOSTNAME_MESSAGE, + }), path: z.string().min(1).optional(), internalPath: z.string().optional(), stripPath: z.boolean().optional(), diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 7bda4615a..b8a2fcec7 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -104,6 +104,7 @@ export * from "./utils/filesystem/directory"; export * from "./utils/filesystem/ssh"; export * from "./utils/git-branch-validation"; export * from "./utils/gpu-setup"; +export * from "./utils/hostname-validation"; export * from "./utils/notifications/build-error"; export * from "./utils/notifications/build-success"; export * from "./utils/notifications/database-backup"; diff --git a/packages/server/src/utils/hostname-validation.ts b/packages/server/src/utils/hostname-validation.ts new file mode 100644 index 000000000..be3407e5e --- /dev/null +++ b/packages/server/src/utils/hostname-validation.ts @@ -0,0 +1,8 @@ +// Valid hostname per RFC 1123: labels of letters, digits and hyphens +// (no leading/trailing hyphen), separated by dots. Underscores are rejected +// because Let's Encrypt refuses to issue certificates for them. +export const VALID_HOSTNAME_REGEX = + /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/; + +export const INVALID_HOSTNAME_MESSAGE = + "Invalid domain name. Use only letters, numbers, hyphens and dots (e.g. example.com). Underscores are not allowed.";