diff --git a/messages/en-US.json b/messages/en-US.json index e85eff9e7..8218cf8d3 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1947,6 +1947,8 @@ "httpMethod": "Scheme", "selectHttpMethod": "Select scheme", "domainPickerSubdomainLabel": "Subdomain", + "domainPickerWildcard": "Wildcard", + "domainPickerWildcardPaidOnly": "Wildcard subdomains are a paid feature. Please upgrade to access this feature.", "domainPickerBaseDomainLabel": "Base Domain", "domainPickerSearchDomains": "Search domains...", "domainPickerNoDomainsFound": "No domains found", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 30dfef353..473d0f852 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -158,7 +158,8 @@ export const resources = pgTable("resources", { maintenanceMessage: text("maintenanceMessage"), maintenanceEstimatedTime: text("maintenanceEstimatedTime"), postAuthPath: text("postAuthPath"), - health: varchar("health") // "healthy", "unhealthy" + health: varchar("health"), // "healthy", "unhealthy" + wildcard: boolean("wildcard").notNull().default(false) }); export const targets = pgTable("targets", { diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 0f0844d2f..6a24ac8dd 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -179,7 +179,8 @@ export const resources = sqliteTable("resources", { maintenanceMessage: text("maintenanceMessage"), maintenanceEstimatedTime: text("maintenanceEstimatedTime"), postAuthPath: text("postAuthPath"), - health: text("health") // "healthy", "unhealthy" + health: text("health"), // "healthy", "unhealthy" + wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false) }); export const targets = sqliteTable("targets", { diff --git a/server/lib/billing/tierMatrix.ts b/server/lib/billing/tierMatrix.ts index 5ae57c8a7..3393dd324 100644 --- a/server/lib/billing/tierMatrix.ts +++ b/server/lib/billing/tierMatrix.ts @@ -23,7 +23,8 @@ export enum TierFeature { HTTPPrivateResources = "httpPrivateResources", // handle downgrade by disabling HTTP private resources DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces StandaloneHealthChecks = "standaloneHealthChecks", - AlertingRules = "alertingRules" + AlertingRules = "alertingRules", + WildcardSubdomain = "wildcardSubdomain" } export const tierMatrix: Record = { @@ -64,5 +65,6 @@ export const tierMatrix: Record = { [TierFeature.HTTPPrivateResources]: ["tier3", "enterprise"], [TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.StandaloneHealthChecks]: ["tier2", "tier3", "enterprise"], - [TierFeature.AlertingRules]: ["tier2", "tier3", "enterprise"] + [TierFeature.AlertingRules]: ["tier2", "tier3", "enterprise"], + [TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"] }; diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index 175c8c79f..136fa1545 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -1,5 +1,6 @@ import { domains, + domainNamespaces, orgDomains, Resource, resourceHeaderAuth, @@ -236,6 +237,7 @@ export async function updateProxyResources( fullDomain: http ? resourceData["full-domain"] : null, subdomain: domain ? domain.subdomain : null, domainId: domain ? domain.domainId : null, + wildcard: domain ? domain.wildcard : false, enabled: resourceEnabled, sso: resourceData.auth?.["sso-enabled"] || false, skipToIdpId: @@ -683,6 +685,7 @@ export async function updateProxyResources( fullDomain: http ? resourceData["full-domain"] : null, subdomain: domain ? domain.subdomain : null, domainId: domain ? domain.domainId : null, + wildcard: domain ? domain.wildcard : false, enabled: resourceEnabled, sso: resourceData.auth?.["sso-enabled"] || false, skipToIdpId: resourceData.auth?.["auto-login-idp"] || null, @@ -1152,7 +1155,9 @@ async function getDomainId( orgId: string, fullDomain: string, trx: Transaction -): Promise<{ subdomain: string | null; domainId: string } | null> { +): Promise<{ subdomain: string | null; domainId: string; wildcard: boolean } | null> { + const isWildcardFullDomain = fullDomain.startsWith("*."); + const possibleDomains = await trx .select() .from(domains) @@ -1165,6 +1170,11 @@ async function getDomainId( } const validDomains = possibleDomains.filter((domain) => { + // Wildcard full-domains are not allowed on CNAME domains + if (isWildcardFullDomain && domain.domains.type === "cname") { + return false; + } + if (domain.domains.type == "ns" || domain.domains.type == "wildcard") { return ( fullDomain === domain.domains.baseDomain || @@ -1182,6 +1192,21 @@ async function getDomainId( const domainSelection = validDomains[0].domains; const baseDomain = domainSelection.baseDomain; + // Wildcard full-domains are not allowed on namespace (provided/free) domains + if (isWildcardFullDomain) { + const [namespaceDomain] = await trx + .select() + .from(domainNamespaces) + .where(eq(domainNamespaces.domainId, domainSelection.domainId)) + .limit(1); + + if (namespaceDomain) { + throw new Error( + `Wildcard full-domains are not supported for provided or free domains: ${fullDomain}` + ); + } + } + // remove the base domain of the domain let subdomain = null; if (fullDomain != baseDomain) { @@ -1191,6 +1216,7 @@ async function getDomainId( // Return the first valid domain return { subdomain: subdomain, - domainId: domainSelection.domainId + domainId: domainSelection.domainId, + wildcard: isWildcardFullDomain }; } diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index e017a16d1..13f4caa8f 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { portRangeStringSchema } from "@server/lib/ip"; import { MaintenanceSchema } from "#dynamic/lib/blueprints/MaintenanceSchema"; import { isValidRegionId } from "@server/db/regions"; +import { wildcardSubdomainSchema } from "@server/lib/schemas"; export const SiteSchema = z.object({ name: z.string().min(1).max(100), @@ -319,6 +320,34 @@ export const ResourceSchema = z message: "Rules have conflicting or invalid priorities (must be unique, including auto-assigned ones)" } + ) + .refine( + (resource) => { + const fullDomain = resource["full-domain"]; + if (!fullDomain || !fullDomain.includes("*")) return true; + + // A wildcard full-domain must be of the form *.labels.basedomain + // Extract the leftmost label(s) before the first non-wildcard segment. + // e.g. "*.level1.example.com" → subdomain candidate is "*.level1" + // We do this by finding the base domain: everything after the first + // real (non-wildcard) dot-separated segment pair. + // + // Simple rule: split on ".", first token must be "*", rest must be + // valid hostname labels, and there must be at least 2 remaining labels + // (so the full domain has a real base domain). + const parts = fullDomain.split("."); + if (parts[0] !== "*") return false; // * must be the very first label + if (parts.includes("*", 1)) return false; // no further wildcards + if (parts.length < 3) return false; // need at least *.label.tld + + const labelRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$|^[a-zA-Z0-9]$/; + return parts.slice(1).every((label) => labelRegex.test(label)); + }, + { + path: ["full-domain"], + message: + 'Wildcard full-domain must have "*" as the leftmost label only, followed by at least two valid hostname labels (e.g. "*.example.com" or "*.level1.example.com"). Patterns like "*example.com" or "level2.*.example.com" are not supported.' + } ); export function isTargetsOnlyResource(resource: any): boolean { diff --git a/server/lib/domainUtils.ts b/server/lib/domainUtils.ts index 3562df683..b8dab5e4c 100644 --- a/server/lib/domainUtils.ts +++ b/server/lib/domainUtils.ts @@ -1,7 +1,7 @@ import { db } from "@server/db"; -import { domains, orgDomains } from "@server/db"; +import { domains, orgDomains, domainNamespaces } from "@server/db"; import { eq, and } from "drizzle-orm"; -import { subdomainSchema } from "@server/lib/schemas"; +import { subdomainSchema, wildcardSubdomainSchema } from "@server/lib/schemas"; import { fromError } from "zod-validation-error"; export type DomainValidationResult = @@ -9,6 +9,7 @@ export type DomainValidationResult = success: true; fullDomain: string; subdomain: string | null; + wildcard: boolean; } | { success: false; @@ -66,6 +67,47 @@ export async function validateAndConstructDomain( }; } + // Detect wildcard subdomain request + const isWildcard = + subdomain !== undefined && + subdomain !== null && + subdomain.includes("*"); + + // Wildcard subdomains are not allowed on CNAME domains + if (isWildcard && domainRes.domains.type === "cname") { + return { + success: false, + error: "Wildcard subdomains are not supported for CNAME domains. CNAME domains must use a specific hostname." + }; + } + + // Wildcard subdomains are not allowed on namespace (provided/free) domains + if (isWildcard) { + const [namespaceDomain] = await db + .select() + .from(domainNamespaces) + .where(eq(domainNamespaces.domainId, domainId)) + .limit(1); + + if (namespaceDomain) { + return { + success: false, + error: "Wildcard subdomains are not supported for provided or free domains. Use a specific subdomain instead." + }; + } + } + + // Validate wildcard subdomain format + if (isWildcard) { + const parsedWildcard = wildcardSubdomainSchema.safeParse(subdomain); + if (!parsedWildcard.success) { + return { + success: false, + error: fromError(parsedWildcard.error).toString() + }; + } + } + // Construct full domain based on domain type let fullDomain = ""; let finalSubdomain = subdomain; @@ -81,13 +123,15 @@ export async function validateAndConstructDomain( finalSubdomain = null; // CNAME domains don't use subdomains } else if (domainRes.domains.type === "wildcard") { if (subdomain !== undefined && subdomain !== null) { - // Validate subdomain format for wildcard domains - const parsedSubdomain = subdomainSchema.safeParse(subdomain); - if (!parsedSubdomain.success) { - return { - success: false, - error: fromError(parsedSubdomain.error).toString() - }; + if (!isWildcard) { + // Validate regular subdomain format for wildcard domains + const parsedSubdomain = subdomainSchema.safeParse(subdomain); + if (!parsedSubdomain.success) { + return { + success: false, + error: fromError(parsedSubdomain.error).toString() + }; + } } fullDomain = `${subdomain}.${domainRes.domains.baseDomain}`; } else { @@ -100,13 +144,14 @@ export async function validateAndConstructDomain( finalSubdomain = null; } - // Convert to lowercase + // Convert to lowercase (preserve * as-is) fullDomain = fullDomain.toLowerCase(); return { success: true, fullDomain, - subdomain: finalSubdomain ?? null + subdomain: isWildcard ? "*" : (finalSubdomain ?? null), + wildcard: isWildcard }; } catch (error) { return { diff --git a/server/lib/schemas.ts b/server/lib/schemas.ts index 5e2bd400b..813849d52 100644 --- a/server/lib/schemas.ts +++ b/server/lib/schemas.ts @@ -1,5 +1,41 @@ import { z } from "zod"; +/** + * Validates a wildcard subdomain passed as the leftmost component of a full domain. + * + * The value represents everything to the left of the base domain, so when combined + * with e.g. "example.com" it must produce a valid SSL-style wildcard hostname. + * + * Valid: + * "*" → *.example.com + * "*.level1" → *.level1.example.com + * + * Invalid: + * "*example" → *example.com (no dot after *) + * "level2.*.level1" → wildcard not in leftmost position + * "*.level1.*" → multiple wildcards + */ +export const wildcardSubdomainSchema = z + .string() + .refine( + (val) => { + // Must start with "*."; the remainder (if any) must be valid hostname labels. + // A bare "*" is also valid (becomes *.baseDomain directly). + if (val === "*") return true; + if (!val.startsWith("*.")) return false; + const rest = val.slice(2); // everything after "*." + // rest must not be empty, must not contain another "*", + // and every label must be a valid hostname label. + if (!rest || rest.includes("*")) return false; + const labelRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/; + return rest.split(".").every((label) => labelRegex.test(label)); + }, + { + message: + 'Invalid wildcard subdomain. The wildcard "*" must be the leftmost label followed by a dot and valid hostname labels (e.g. "*" or "*.level1"). Patterns like "*example", "level2.*.level1", or multiple wildcards are not supported.' + } + ); + export const subdomainSchema = z .string() .regex( diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index f026166a6..d3ace5adc 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -17,7 +17,7 @@ import createHttpError from "http-errors"; import { eq, and } from "drizzle-orm"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; -import { subdomainSchema } from "@server/lib/schemas"; +import { subdomainSchema, wildcardSubdomainSchema } from "@server/lib/schemas"; import config from "@server/lib/config"; import { OpenAPITags, registry } from "@server/openApi"; import { build } from "@server/build"; @@ -25,6 +25,7 @@ import { createCertificate } from "#dynamic/routers/certificates/createCertifica import { getUniqueResourceName } from "@server/db/names"; import { validateAndConstructDomain } from "@server/lib/domainUtils"; import { isSubscribed } from "#dynamic/lib/isSubscribed"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; const createResourceParamsSchema = z.strictObject({ @@ -44,7 +45,10 @@ const createHttpResourceSchema = z .refine( (data) => { if (data.subdomain) { - return subdomainSchema.safeParse(data.subdomain).success; + return ( + subdomainSchema.safeParse(data.subdomain).success || + wildcardSubdomainSchema.safeParse(data.subdomain).success + ); } return true; }, @@ -198,6 +202,22 @@ async function createHttpResource( const subdomain = parsedBody.data.subdomain; const stickySession = parsedBody.data.stickySession; + // Wildcard subdomains are a paid feature + if (subdomain && subdomain.includes("*")) { + const isLicensed = await isLicensedOrSubscribed( + orgId, + tierMatrix.wildcardSubdomain + ); + if (!isLicensed) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Wildcard subdomains are not supported on your current plan. Please upgrade to access this feature." + ) + ); + } + } + if (build == "saas" && !isSubscribed(orgId!, tierMatrix.domainNamespaces)) { // grandfather in existing users const lastAllowedDate = new Date("2026-04-13"); @@ -232,7 +252,7 @@ async function createHttpResource( return next(createHttpError(HttpCode.BAD_REQUEST, domainResult.error)); } - const { fullDomain, subdomain: finalSubdomain } = domainResult; + const { fullDomain, subdomain: finalSubdomain, wildcard } = domainResult; logger.debug(`Full domain: ${fullDomain}`); @@ -299,7 +319,8 @@ async function createHttpResource( protocol: "tcp", ssl: true, stickySession: stickySession, - postAuthPath: postAuthPath + postAuthPath: postAuthPath, + wildcard }) .returning(); diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 21a923704..feb12e90e 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -16,8 +16,11 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import config from "@server/lib/config"; -import { tlsNameSchema } from "@server/lib/schemas"; -import { subdomainSchema } from "@server/lib/schemas"; +import { + tlsNameSchema, + subdomainSchema, + wildcardSubdomainSchema +} from "@server/lib/schemas"; import { registry } from "@server/openApi"; import { OpenAPITags } from "@server/openApi"; import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; @@ -73,7 +76,10 @@ const updateHttpResourceBodySchema = z .refine( (data) => { if (data.subdomain) { - return subdomainSchema.safeParse(data.subdomain).success; + return ( + subdomainSchema.safeParse(data.subdomain).success || + wildcardSubdomainSchema.safeParse(data.subdomain).success + ); } return true; }, @@ -318,6 +324,22 @@ async function updateHttpResource( } } + // Wildcard subdomains are a paid feature + if (updateData.subdomain && updateData.subdomain.includes("*")) { + const isLicensed = await isLicensedOrSubscribed( + resource.orgId, + tierMatrix.wildcardSubdomain + ); + if (!isLicensed) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Wildcard subdomains are not supported on your current plan. Please upgrade to access this feature." + ) + ); + } + } + if (updateData.domainId) { const domainId = updateData.domainId; @@ -362,7 +384,11 @@ async function updateHttpResource( ); } - const { fullDomain, subdomain: finalSubdomain } = domainResult; + const { + fullDomain, + subdomain: finalSubdomain, + wildcard + } = domainResult; logger.debug(`Full domain: ${fullDomain}`); @@ -419,7 +445,7 @@ async function updateHttpResource( if (fullDomain && fullDomain !== resource.fullDomain) { await db .update(resources) - .set({ fullDomain }) + .set({ fullDomain, wildcard }) .where(eq(resources.resourceId, resource.resourceId)); } diff --git a/src/components/DomainPicker.tsx b/src/components/DomainPicker.tsx index 7a90dfa67..281248327 100644 --- a/src/components/DomainPicker.tsx +++ b/src/components/DomainPicker.tsx @@ -27,6 +27,7 @@ import { cn } from "@/lib/cn"; import { finalizeSubdomainSanitize, isValidSubdomainStructure, + isWildcardSubdomain, sanitizeInputRaw, validateByDomainType } from "@/lib/subdomain-utils"; @@ -77,6 +78,7 @@ interface DomainPickerProps { subdomain?: string; fullDomain: string; baseDomain: string; + wildcard?: boolean; } | null ) => void; cols?: number; @@ -85,6 +87,7 @@ interface DomainPickerProps { defaultSubdomain?: string | null; defaultDomainId?: string | null; warnOnProvidedDomain?: boolean; + allowWildcard?: boolean; } export default function DomainPicker({ @@ -95,23 +98,33 @@ export default function DomainPicker({ defaultSubdomain, defaultFullDomain, defaultDomainId, - warnOnProvidedDomain = false + warnOnProvidedDomain = false, + allowWildcard = false }: DomainPickerProps) { const { env } = useEnvContext(); const { user } = useUserContext(); const api = createApiClient({ env }); const t = useTranslations(); - const { hasSaasSubscription } = usePaidStatus(); + const { hasSaasSubscription, isPaidUser } = usePaidStatus(); const requiresPaywall = build === "saas" && !hasSaasSubscription(tierMatrix[TierFeature.DomainNamespaces]) && new Date(user.dateCreated) > new Date("2026-04-13"); + const wildcardAllowed = + allowWildcard && isPaidUser(tierMatrix[TierFeature.WildcardSubdomain]); + const { data = [], isLoading: loadingDomains } = useQuery( orgQueries.domains({ orgId }) ); + // Wildcard mode toggle — only relevant when allowWildcard is true, the + // user has a paid plan, and the selected base domain supports it. + const [wildcardMode, setWildcardMode] = useState( + wildcardAllowed && !!defaultSubdomain && isWildcardSubdomain(defaultSubdomain) + ); + if (!env.flags.usePangolinDns) { hideFreeDomain = true; } @@ -180,13 +193,15 @@ export default function DomainPicker({ firstOrExistingDomain.type !== "cname" ? defaultSubdomain?.trim() || undefined : undefined; + const isWc = allowWildcard && !!sub && isWildcardSubdomain(sub); onDomainChange?.({ domainId: firstOrExistingDomain.domainId, type: "organization", subdomain: sub, fullDomain: sub ? `${sub}.${base}` : base, - baseDomain: base + baseDomain: base, + wildcard: isWc }); } } @@ -285,7 +300,7 @@ export default function DomainPicker({ }, [userInput, debouncedCheckAvailability, selectedBaseDomain]); const finalizeSubdomain = (sub: string, base: DomainOption): string => { - const sanitized = finalizeSubdomainSanitize(sub); + const sanitized = finalizeSubdomainSanitize(sub, wildcardAllowed && wildcardMode); if (!sanitized) { toast({ @@ -301,7 +316,8 @@ export default function DomainPicker({ base.type === "provided-search" ? "provided-search" : "organization", - domainType: base.domainType + domainType: base.domainType, + allowWildcard: wildcardAllowed && wildcardMode }); if (!ok) { @@ -330,7 +346,7 @@ export default function DomainPicker({ }; const handleSubdomainChange = (value: string) => { - const raw = sanitizeInputRaw(value); + const raw = sanitizeInputRaw(value, wildcardAllowed && wildcardMode); setSubdomainInput(raw); setSelectedProvidedDomain(null); @@ -338,13 +354,15 @@ export default function DomainPicker({ const fullDomain = raw ? `${raw}.${selectedBaseDomain.domain}` : selectedBaseDomain.domain; + const isWc = wildcardAllowed && wildcardMode && isWildcardSubdomain(raw); onDomainChange?.({ domainId: selectedBaseDomain.domainId!, type: "organization", subdomain: raw || undefined, fullDomain, - baseDomain: selectedBaseDomain.domain + baseDomain: selectedBaseDomain.domain, + wildcard: isWc }); } }; @@ -366,6 +384,18 @@ export default function DomainPicker({ const handleBaseDomainSelect = (option: DomainOption) => { let sub = subdomainInput; + // Wildcard mode is not applicable for cname or provided-search domains, + // or when the user doesn't have a paid plan. + const newWildcardMode = + wildcardAllowed && + wildcardMode && + option.type === "organization" && + option.domainType !== "cname"; + + if (newWildcardMode !== wildcardMode) { + setWildcardMode(newWildcardMode); + } + if (sub && sub.trim() !== "") { sub = finalizeSubdomain(sub, option) || ""; setSubdomainInput(sub); @@ -389,6 +419,7 @@ export default function DomainPicker({ } const fullDomain = sub ? `${sub}.${option.domain}` : option.domain; + const isWc = newWildcardMode && !!sub && isWildcardSubdomain(sub); if (option.type === "provided-search") { onDomainChange?.(null); // prevent the modal from closing with `.Free Provided domain` @@ -402,7 +433,8 @@ export default function DomainPicker({ ? sub || undefined : undefined, fullDomain, - baseDomain: option.domain + baseDomain: option.domain, + wildcard: isWc }); } }; @@ -431,7 +463,8 @@ export default function DomainPicker({ selectedBaseDomain.type === "provided-search" ? "provided-search" : "organization", - domainType: selectedBaseDomain.domainType + domainType: selectedBaseDomain.domainType, + allowWildcard: wildcardAllowed && wildcardMode }) : true; @@ -439,6 +472,31 @@ export default function DomainPicker({ selectedBaseDomain && selectedBaseDomain.type === "organization" && selectedBaseDomain.domainType !== "cname"; + + // Wildcard toggle is shown when the caller opts in and the selected domain + // supports it (ns or wildcard type). Shown even when unpaid so we can + // render a disabled state explaining it's a paid feature. + const showWildcardToggle = + allowWildcard && + showSubdomainInput && + (selectedBaseDomain?.domainType === "ns" || + selectedBaseDomain?.domainType === "wildcard"); + + const handleWildcardModeChange = (enabled: boolean) => { + setWildcardMode(enabled); + // Reset subdomain input when toggling modes to avoid invalid state + setSubdomainInput(""); + if (selectedBaseDomain?.type === "organization") { + onDomainChange?.({ + domainId: selectedBaseDomain.domainId!, + type: "organization", + subdomain: undefined, + fullDomain: selectedBaseDomain.domain, + baseDomain: selectedBaseDomain.domain, + wildcard: enabled ? true : false + }); + } + }; const showProvidedDomainSearch = selectedBaseDomain?.type === "provided-search"; @@ -463,9 +521,35 @@ export default function DomainPicker({
- +
+ + {showWildcardToggle && ( + + )} +
{showSubdomainInput && subdomainInput && - !isValidSubdomainStructure(subdomainInput) && ( + !isValidSubdomainStructure(subdomainInput, wildcardAllowed && wildcardMode) && (

{t("domainPickerInvalidSubdomainStructure")}

diff --git a/src/lib/subdomain-utils.ts b/src/lib/subdomain-utils.ts index 10c027a89..1c5e79207 100644 --- a/src/lib/subdomain-utils.ts +++ b/src/lib/subdomain-utils.ts @@ -5,16 +5,58 @@ export const MULTI_LABEL_RE = /^[\p{L}\p{N}-]+(\.[\p{L}\p{N}-]+)*$/u; // ns/wild export const SINGLE_LABEL_STRICT_RE = /^[\p{L}\p{N}](?:[\p{L}\p{N}-]*[\p{L}\p{N}])?$/u; // start/end alnum -export function sanitizeInputRaw(input: string): string { +/** + * A wildcard subdomain is either bare "*" or "*.label1.label2…" where every + * label after the dot is a valid hostname label. This mirrors the shape that + * the server's `wildcardSubdomainSchema` accepts. + */ +export const WILDCARD_SUBDOMAIN_RE = + /^\*(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/; + +export function isWildcardSubdomain(input: string): boolean { + return WILDCARD_SUBDOMAIN_RE.test(input); +} + +export function sanitizeInputRaw(input: string, allowWildcard = false): string { if (!input) return ""; + // When wildcard mode is active, preserve a leading "* " / "*." prefix and + // only sanitize the remainder so the user can type "*.level1" naturally. + if (allowWildcard && input.startsWith("*")) { + const rest = input.slice(1); + const sanitizedRest = rest + .toLowerCase() + .normalize("NFC") + .replace(/[^\p{L}\p{N}.-]/gu, ""); + return "*" + sanitizedRest; + } return input .toLowerCase() .normalize("NFC") // normalize Unicode .replace(/[^\p{L}\p{N}.-]/gu, ""); // allow Unicode letters, numbers, dot, hyphen } -export function finalizeSubdomainSanitize(input: string): string { +export function finalizeSubdomainSanitize( + input: string, + allowWildcard = false +): string { if (!input) return ""; + + // If the input is a valid wildcard and the caller permits it, keep it as-is + // (just lowercase the non-wildcard labels). + if (allowWildcard && input.startsWith("*")) { + const rest = input.slice(1); // everything after the leading "*" + const sanitizedRest = rest + .toLowerCase() + .normalize("NFC") + .replace(/[^\p{L}\p{N}.-]/gu, "") + .replace(/\.{2,}/g, ".") + .replace(/^-+|-+$/g, "") + .replace(/(\.-)|(-\.)/g, "."); + const candidate = "*" + sanitizedRest; + // Return only if it still forms a valid wildcard after sanitizing + return isWildcardSubdomain(candidate) ? candidate : ""; + } + return input .toLowerCase() .normalize("NFC") @@ -30,6 +72,7 @@ export function validateByDomainType( domainType: { type: "provided-search" | "organization"; domainType?: "ns" | "cname" | "wildcard"; + allowWildcard?: boolean; } ): boolean { if (!domainType) return false; @@ -46,6 +89,12 @@ export function validateByDomainType( domainType.domainType === "wildcard" ) { if (subdomain === "") return true; + + // Wildcard subdomain validation (only when caller opts in) + if (domainType.allowWildcard && subdomain.startsWith("*")) { + return isWildcardSubdomain(subdomain); + } + if (!MULTI_LABEL_RE.test(subdomain)) return false; const labels = subdomain.split("."); return labels.every( @@ -57,10 +106,19 @@ export function validateByDomainType( return false; } -export const isValidSubdomainStructure = (input: string): boolean => { +export const isValidSubdomainStructure = ( + input: string, + allowWildcard = false +): boolean => { + if (!input) return false; + + // A valid wildcard subdomain is structurally valid when the caller allows it + if (allowWildcard && input.startsWith("*")) { + return isWildcardSubdomain(input); + } + const regex = /^(?!-)([\p{L}\p{N}-]{1,63})(? regex.test(label));