Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/modules/reverse-proxy/ReverseProxyModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
7 changes: 4 additions & 3 deletions src/modules/reverse-proxy/domain/ReverseProxyDomainInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)"}
Expand Down
70 changes: 70 additions & 0 deletions src/modules/reverse-proxy/domain/subdomain.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { isValidSubdomain, sanitizeSubdomain } from "./subdomain.js";

type Case<T> = { input: string; expected: T; desc?: string };

function run<T>(name: string, cases: Case<T>[], 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<string>(
"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<boolean>(
"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);
21 changes: 21 additions & 0 deletions src/modules/reverse-proxy/domain/subdomain.ts
Original file line number Diff line number Diff line change
@@ -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);
}
7 changes: 2 additions & 5 deletions src/modules/reverse-proxy/domain/useReverseProxyDomain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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, "-") ?? "")
);
Comment on lines 91 to 95

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Traced this and the bug is real, but I would rather not fix it in this PR.

Confirmed: domains comes from useFetchApi in ReverseProxiesProvider and is passed straight into the modal, which mounts on modalOpen with no loading guard. On a cold SWR cache, parseDomain runs with domains === undefined, falls through to the first-dot split, and dev.app.example.com initializes as subdomain dev + baseDomain app.example.com. The useState initializer never re-runs when domains arrive, so it stays wrong.

Two reasons to keep it separate:

  1. It is pre-existing on main. Both the first-dot fallback and the once-only initializer predate this PR, and nothing here changes that path — this PR only touches the sanitize/validate step. You are right that multi-label domains make it easier to hit, but that is a change in the data, not in the code path.
  2. It is the edit flow, not the input-stripping bug in Reverse Proxy service modal strips dots from the subdomain input, blocking nested subdomains the API accepts #667, and the fix needs a dirty-ref plus a sync effect — new state machinery that would not be covered by this PR title or its tests.

Happy to open a follow-up issue with the above repro, or send a separate PR if a maintainer would rather have it bundled.

});

Expand Down