Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
59 changes: 59 additions & 0 deletions src/modules/reverse-proxy/domain/subdomain.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
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: "", expected: true, desc: "empty handled by require_subdomain" },
{ 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);
16 changes: 16 additions & 0 deletions src/modules/reverse-proxy/domain/subdomain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Live input filter. Dots are kept so multi-label subdomains ("dev.app") can
// be typed at all — dot placement is only checked on submit, because an
// anchored check would reject valid in-progress input like "dev." mid-typing.

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.

Fixed in ead8c19 — the comment was stale against my own final code. It now says the sanitizer deliberately skips dot-placement checks (so dev. survives mid-typing), and isValidSubdomain carries a note that it drives both the inline error and the modal gate.

export function sanitizeSubdomain(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9.-]/g, "");
}

// Dot-separated DNS labels. Empty labels are rejected, so leading/trailing
// dots and consecutive dots ("dev..app") never reach the API.
const SUBDOMAIN_PATTERN = /^[a-z0-9-]+(\.[a-z0-9-]+)*$/;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

// 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