Support unicode with subdomain sanitized

This commit is contained in:
Pallavi
2025-08-31 22:45:42 +05:30
parent 8a62f12e8b
commit 7d5961cf50
4 changed files with 29 additions and 32 deletions

View File

@@ -55,7 +55,7 @@ import { Globe } from "lucide-react";
import { build } from "@server/build"; import { build } from "@server/build";
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils"; import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
import { DomainRow } from "../../../domains/DomainsTable"; import { DomainRow } from "../../../domains/DomainsTable";
import { toUnicode } from "punycode"; import { toASCII, toUnicode } from "punycode";
export default function GeneralForm() { export default function GeneralForm() {
const [formKey, setFormKey] = useState(0); const [formKey, setFormKey] = useState(0);
@@ -82,7 +82,7 @@ export default function GeneralForm() {
const [loadingPage, setLoadingPage] = useState(true); const [loadingPage, setLoadingPage] = useState(true);
const [resourceFullDomain, setResourceFullDomain] = useState( const [resourceFullDomain, setResourceFullDomain] = useState(
`${resource.ssl ? "https" : "http"}://${resource.fullDomain}` `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`
); );
const [selectedDomain, setSelectedDomain] = useState<{ const [selectedDomain, setSelectedDomain] = useState<{
domainId: string; domainId: string;
@@ -186,7 +186,7 @@ export default function GeneralForm() {
{ {
enabled: data.enabled, enabled: data.enabled,
name: data.name, name: data.name,
subdomain: data.subdomain, subdomain: data.subdomain ? toASCII(data.subdomain) : undefined,
domainId: data.domainId, domainId: data.domainId,
proxyPort: data.proxyPort, proxyPort: data.proxyPort,
// ...(!resource.http && { // ...(!resource.http && {
@@ -478,7 +478,6 @@ export default function GeneralForm() {
setEditDomainOpen(false); setEditDomainOpen(false);
toast({ toast({
title: "Domain sanitized",
description: `Final domain: ${sanitizedFullDomain}`, description: `Final domain: ${sanitizedFullDomain}`,
}); });
} }

View File

@@ -89,7 +89,7 @@ import { isTargetValid } from "@server/lib/validators";
import { ListTargetsResponse } from "@server/routers/target"; import { ListTargetsResponse } from "@server/routers/target";
import { DockerManager, DockerState } from "@app/lib/docker"; import { DockerManager, DockerState } from "@app/lib/docker";
import { parseHostTarget } from "@app/lib/parseHostTarget"; import { parseHostTarget } from "@app/lib/parseHostTarget";
import { toUnicode } from 'punycode'; import { toASCII, toUnicode } from 'punycode';
import { DomainRow } from "../../domains/DomainsTable"; import { DomainRow } from "../../domains/DomainsTable";
const baseResourceFormSchema = z.object({ const baseResourceFormSchema = z.object({
@@ -329,7 +329,7 @@ export default function Page() {
if (isHttp) { if (isHttp) {
const httpData = httpForm.getValues(); const httpData = httpForm.getValues();
Object.assign(payload, { Object.assign(payload, {
subdomain: httpData.subdomain, subdomain: httpData.subdomain ? toASCII(httpData.subdomain) : undefined,
domainId: httpData.domainId, domainId: httpData.domainId,
protocol: "tcp" protocol: "tcp"
}); });

View File

@@ -336,8 +336,13 @@ export default function DomainPicker2({
const handleBaseDomainSelect = (option: DomainOption) => { const handleBaseDomainSelect = (option: DomainOption) => {
let sub = subdomainInput; let sub = subdomainInput;
sub = finalizeSubdomain(sub, option); if (sub && sub.trim() !== "") {
setSubdomainInput(sub); sub = finalizeSubdomain(sub, option) || "";
setSubdomainInput(sub);
} else {
sub = "";
setSubdomainInput("");
}
if (option.type === "provided-search") { if (option.type === "provided-search") {
setUserInput(""); setUserInput("");
@@ -409,11 +414,6 @@ export default function DomainPicker2({
sortedAvailableOptions.length > providedDomainsShown; sortedAvailableOptions.length > providedDomainsShown;
const isValidDomainCharacter = (char: string) => {
// Allow Unicode letters, numbers, hyphens, and periods
return /[\p{L}\p{N}.-]/u.test(char);
};
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
@@ -444,16 +444,10 @@ export default function DomainPicker2({
"border-red-500 focus:border-red-500" "border-red-500 focus:border-red-500"
)} )}
onChange={(e) => { onChange={(e) => {
const rawInput = e.target.value;
const validInput = rawInput
.split("")
.filter((char) => isValidDomainCharacter(char))
.join("");
if (showProvidedDomainSearch) { if (showProvidedDomainSearch) {
handleProvidedDomainInputChange(validInput); handleProvidedDomainInputChange(e.target.value);
} else { } else {
handleSubdomainChange(validInput); handleSubdomainChange(e.target.value);
} }
}} }}
/> />

View File

@@ -1,29 +1,32 @@
export type DomainType = "organization" | "provided" | "provided-search"; export type DomainType = "organization" | "provided" | "provided-search";
export const SINGLE_LABEL_RE = /^[a-z0-9-]+$/i; // provided-search (no dots) export const SINGLE_LABEL_RE = /^[\p{L}\p{N}-]+$/u; // provided-search (no dots)
export const MULTI_LABEL_RE = /^[a-z0-9-]+(\.[a-z0-9-]+)*$/i; // ns/wildcard export const MULTI_LABEL_RE = /^[\p{L}\p{N}-]+(\.[\p{L}\p{N}-]+)*$/u; // ns/wildcard
export const SINGLE_LABEL_STRICT_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i; // start/end alnum 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 { export function sanitizeInputRaw(input: string): string {
if (!input) return ""; if (!input) return "";
return input.toLowerCase().replace(/[^a-z0-9.-]/g, ""); 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): string {
if (!input) return ""; if (!input) return "";
return input return input
.toLowerCase() .toLowerCase()
.replace(/[^a-z0-9.-]/g, "") // allow only valid chars .normalize("NFC")
.replace(/\.{2,}/g, ".") // collapse multiple dots .replace(/[^\p{L}\p{N}.-]/gu, "") // allow Unicode
.replace(/^-+|-+$/g, "") // strip leading/trailing hyphens .replace(/\.{2,}/g, ".") // collapse multiple dots
.replace(/^\.+|\.+$/g, ""); // strip leading/trailing dots .replace(/^-+|-+$/g, "") // strip leading/trailing hyphens
.replace(/^\.+|\.+$/g, "") // strip leading/trailing dots
.replace(/(\.-)|(-\.)/g, "."); // fix illegal dot-hyphen combos
} }
export function validateByDomainType(subdomain: string, domainType: { type: "provided-search" | "organization"; domainType?: "ns" | "cname" | "wildcard" } ): boolean { export function validateByDomainType(subdomain: string, domainType: { type: "provided-search" | "organization"; domainType?: "ns" | "cname" | "wildcard" } ): boolean {
if (!domainType) return false; if (!domainType) return false;
@@ -47,7 +50,7 @@ export function validateByDomainType(subdomain: string, domainType: { type: "pro
export const isValidSubdomainStructure = (input: string): boolean => { export const isValidSubdomainStructure = (input: string): boolean => {
const regex = /^(?!-)([a-zA-Z0-9-]{1,63})(?<!-)$/; const regex = /^(?!-)([\p{L}\p{N}-]{1,63})(?<!-)$/u;
if (!input) return false; if (!input) return false;
if (input.includes("..")) return false; if (input.includes("..")) return false;
@@ -57,3 +60,4 @@ export const isValidSubdomainStructure = (input: string): boolean => {