From b2692cd594eb2f6ba92855f9e44157b791511a1e Mon Sep 17 00:00:00 2001
From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com>
Date: Sun, 5 Jul 2026 14:53:01 -0600
Subject: [PATCH] fix(domain): validate hostname format to reject invalid
characters (#4729)
* fix(domain): validate hostname format to reject invalid characters
Underscores and other invalid characters were accepted in domain
inputs with no validation, causing Let's Encrypt to silently fail
certificate issuance while Dokploy fell back to a self-signed cert.
Fixes #4716
* fix(create-server): update SSH key label for clarity in server creation form
---
.../utils/hostname-validation.test.ts | 46 +++++++++++++++++++
.../application/domains/handle-domain.tsx | 9 +++-
.../servers/welcome-stripe/create-server.tsx | 4 +-
.../dashboard/settings/web-domain.tsx | 12 ++++-
apps/dokploy/server/db/validations/domain.ts | 14 +++++-
packages/server/src/db/validations/domain.ts | 14 +++++-
packages/server/src/index.ts | 1 +
.../server/src/utils/hostname-validation.ts | 8 ++++
8 files changed, 99 insertions(+), 9 deletions(-)
create mode 100644 apps/dokploy/__test__/utils/hostname-validation.test.ts
create mode 100644 packages/server/src/utils/hostname-validation.ts
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.";