diff --git a/src/modules/reverse-proxy/ReverseProxyModal.tsx b/src/modules/reverse-proxy/ReverseProxyModal.tsx index 6e24df90a..b7057dffc 100644 --- a/src/modules/reverse-proxy/ReverseProxyModal.tsx +++ b/src/modules/reverse-proxy/ReverseProxyModal.tsx @@ -62,6 +62,7 @@ import { import { useReverseProxies } from "@/contexts/ReverseProxiesProvider"; import ReverseProxyDomainInput from "./domain/ReverseProxyDomainInput"; import { useReverseProxyDomain } from "./domain/useReverseProxyDomain"; +import { isValidSubdomain } from "./domain/subdomain"; import AuthPasswordModal from "@/modules/reverse-proxy/auth/AuthPasswordModal"; import AuthHeaderModal from "@/modules/reverse-proxy/auth/AuthHeaderModal"; import AuthPinModal from "@/modules/reverse-proxy/auth/AuthPinModal"; @@ -371,6 +372,7 @@ export default function ReverseProxyModal({ const isSubdomainValid = baseDomain.length > 0 && !domainAlreadyExists && + isValidSubdomain(subdomain) && (subdomain.length > 0 || !subdomainRequired); const isValidPort = (port: number) => port >= 1 && port <= 65535; const hasHttpEndpoint = !isL4Mode && targets.length > 0; diff --git a/src/modules/reverse-proxy/domain/ReverseProxyDomainInput.tsx b/src/modules/reverse-proxy/domain/ReverseProxyDomainInput.tsx index 6a23abd41..21bc19832 100644 --- a/src/modules/reverse-proxy/domain/ReverseProxyDomainInput.tsx +++ b/src/modules/reverse-proxy/domain/ReverseProxyDomainInput.tsx @@ -6,6 +6,7 @@ import React from "react"; import { CustomDomainSelector } from "./CustomDomainSelector"; import { isNetBirdCloud } from "@utils/netbird"; import InlineLink from "@components/InlineLink"; +import { isValidSubdomain, sanitizeSubdomain } from "./subdomain"; type Props = { subdomain: string; @@ -43,13 +44,13 @@ export default function ReverseProxyDomainInput({ data-testid="proxy-subdomain-input" value={subdomain} onChange={(e) => { - onSubdomainChange( - e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""), - ); + onSubdomainChange(sanitizeSubdomain(e.target.value)); }} error={ domainAlreadyExists ? "This domain is already used by another service." + : !isValidSubdomain(subdomain) + ? "Enter a valid subdomain, e.g. myapp or dev.myapp." : undefined } placeholder={subdomainRequired ? "myapp" : "myapp (optional)"} diff --git a/src/modules/reverse-proxy/domain/subdomain.test.ts b/src/modules/reverse-proxy/domain/subdomain.test.ts new file mode 100644 index 000000000..8efa780dc --- /dev/null +++ b/src/modules/reverse-proxy/domain/subdomain.test.ts @@ -0,0 +1,70 @@ +import { isValidSubdomain, sanitizeSubdomain } from "./subdomain.js"; + +type Case = { input: string; expected: T; desc?: string }; + +function run(name: string, cases: Case[], fn: (s: string) => T): number { + console.log(`\n=== ${name} ===`); + let failures = 0; + for (const { input, expected, desc } of cases) { + const actual = fn(input); + const ok = actual === expected; + if (!ok) failures++; + const label = desc + ? `${JSON.stringify(input)} (${desc})` + : JSON.stringify(input); + console.log( + `${ok ? "✓" : "✗"} ${label.padEnd(40)} → ${JSON.stringify(actual)}` + + (ok ? "" : ` (expected: ${JSON.stringify(expected)})`), + ); + } + return failures; +} + +let failures = 0; + +failures += run( + "sanitizeSubdomain", + [ + { input: "dev.app", expected: "dev.app", desc: "keeps dots (#667)" }, + { input: "a.b.c.d", expected: "a.b.c.d", desc: "deeply nested" }, + { input: "DEV.App", expected: "dev.app", desc: "lowercased" }, + { input: "my-app", expected: "my-app", desc: "keeps hyphens" }, + { input: "dev.app!", expected: "dev.app", desc: "strips punctuation" }, + { input: "dev app", expected: "devapp", desc: "strips spaces" }, + { input: "dev_app", expected: "devapp", desc: "strips underscores" }, + { input: "dev.", expected: "dev.", desc: "in-progress typing survives" }, + { input: "", expected: "" }, + ], + sanitizeSubdomain, +); + +failures += run( + "isValidSubdomain", + [ + { input: "myapp", expected: true, desc: "single label" }, + { input: "dev.app", expected: true, desc: "multi-label (#667)" }, + { input: "a.b.c.d", expected: true, desc: "deeply nested" }, + { input: "my-app.dev", expected: true, desc: "hyphenated label" }, + { input: "a", expected: true, desc: "single character" }, + { input: "a-b.c-d", expected: true, desc: "hyphen inside every label" }, + { input: "", expected: true, desc: "empty handled by require_subdomain" }, + { input: "-app", expected: false, desc: "leading hyphen" }, + { input: "app-", expected: false, desc: "trailing hyphen" }, + { input: "-", expected: false, desc: "bare hyphen" }, + { input: "dev.-app", expected: false, desc: "leading hyphen, later label" }, + { + input: "dev-.app", + expected: false, + desc: "trailing hyphen, first label", + }, + { input: ".app", expected: false, desc: "leading dot" }, + { input: "dev.", expected: false, desc: "trailing dot" }, + { input: "dev..app", expected: false, desc: "consecutive dots" }, + { input: ".", expected: false, desc: "bare dot" }, + { input: "..", expected: false, desc: "bare dots" }, + ], + isValidSubdomain, +); + +console.log(`\n${failures} test(s) failed`); +process.exit(failures > 0 ? 1 : 0); diff --git a/src/modules/reverse-proxy/domain/subdomain.ts b/src/modules/reverse-proxy/domain/subdomain.ts new file mode 100644 index 000000000..7f885a0d7 --- /dev/null +++ b/src/modules/reverse-proxy/domain/subdomain.ts @@ -0,0 +1,21 @@ +// Live input filter, applied on every keystroke. Dots are kept so multi-label +// subdomains ("dev.app") can be typed at all. It deliberately does not check +// dot placement: "dev." is a valid step on the way to "dev.app", so rejecting +// it here would make the field impossible to type in. Shape is checked +// separately by isValidSubdomain. +export function sanitizeSubdomain(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9.-]/g, ""); +} + +// Dot-separated DNS labels (RFC 1123): each label is alphanumeric at both +// ends, with hyphens allowed only inside. Rejects empty labels, so leading or +// trailing dots and consecutive dots ("dev..app") never reach the API. +const SUBDOMAIN_PATTERN = + /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/; + +// Drives both the inline error in the input and the submit gate in the modal. +// An empty subdomain is valid here; whether one is required at all is a +// separate concern (ReverseProxyDomain.require_subdomain). +export function isValidSubdomain(value: string): boolean { + return value === "" || SUBDOMAIN_PATTERN.test(value); +} diff --git a/src/modules/reverse-proxy/domain/useReverseProxyDomain.ts b/src/modules/reverse-proxy/domain/useReverseProxyDomain.ts index 25eca5c50..7d86487d9 100644 --- a/src/modules/reverse-proxy/domain/useReverseProxyDomain.ts +++ b/src/modules/reverse-proxy/domain/useReverseProxyDomain.ts @@ -5,6 +5,7 @@ import { ReverseProxyDomainType, } from "@/interfaces/ReverseProxy"; import { useReverseProxies } from "@/contexts/ReverseProxiesProvider"; +import { sanitizeSubdomain } from "./subdomain"; // Helper to parse domain into subdomain and base domain. // When availableDomains is provided, matches against them first (longest match wins) @@ -90,11 +91,7 @@ export function useReverseProxyDomain({ const [subdomain, setSubdomain] = useState(() => { return ( parsed?.subdomain || - initialSubdomain - ?.toLowerCase() - .replace(/\s+/g, "-") - .replace(/[^a-z0-9-]/g, "") || - "" + sanitizeSubdomain(initialSubdomain?.replace(/\s+/g, "-") ?? "") ); });