fix(dashboard): prefer public endpoint URLs (#7547)

* fix(dashboard): prefer public endpoint URLs

* docs: add changelog fragment for #7547

* test(dashboard): cover onboarding public endpoint

* refactor(hooks): split display-URL predicates below complexity gate

Decompose isPrivateIpv4 and isPublicDisplayBaseUrl (both over the ESLint
complexity gate of 15) into small named predicates. Behavior is unchanged:

- isPrivateIpv4 now checks a PRIVATE_IPV4_RANGES table (RFC1918 +
  special-use ranges) through isInIpv4Range instead of one long chain
  of ||/&& comparisons.
- isPublicDisplayBaseUrl now delegates to isSupportedProtocol,
  isLoopbackHostname, isMulticastDnsHostname and isNonPublicIpv6 (itself
  split into isIpv6LoopbackOrUnspecified / isIpv6UniqueLocal /
  isIpv6LinkLocal), preserving the isIpv6 gate so hostnames that merely
  start with "fc"/"fd" (e.g. fdroid.example.com) are not misclassified
  as IPv6 unique-local addresses.

Adds IPv4 range-boundary and IPv6-gate regression tests; all existing
assertions are unchanged.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: nguyenha935 <208228297+nguyenha935@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
nguyenha935
2026-07-19 01:14:18 +07:00
committed by GitHub
parent 65fbba4893
commit a7dba3bbcc
10 changed files with 429 additions and 54 deletions

View File

@@ -10,13 +10,122 @@ function normalizeUrl(value?: string): string | null {
return trimmed.replace(/\/+$/, "");
}
/**
* One RFC1918 / special-use IPv4 range, expressed as closed intervals on the
* first two octets. Unbounded ends use +/-Infinity so a single numeric
* comparison covers them without an extra branch.
*/
interface Ipv4Range {
readonly firstMin: number;
readonly firstMax: number;
readonly secondMin: number;
readonly secondMax: number;
}
/** RFC1918 + special-use IPv4 ranges treated as non-public for display purposes. */
const PRIVATE_IPV4_RANGES: readonly Ipv4Range[] = [
{ firstMin: 0, firstMax: 0, secondMin: -Infinity, secondMax: Infinity }, // 0.0.0.0/8 ("this" network)
{ firstMin: 10, firstMax: 10, secondMin: -Infinity, secondMax: Infinity }, // RFC1918 10.0.0.0/8
{ firstMin: 127, firstMax: 127, secondMin: -Infinity, secondMax: Infinity }, // loopback 127.0.0.0/8
{ firstMin: 224, firstMax: Infinity, secondMin: -Infinity, secondMax: Infinity }, // multicast/reserved/broadcast
{ firstMin: 100, firstMax: 100, secondMin: 64, secondMax: 127 }, // CGNAT RFC6598 100.64.0.0/10
{ firstMin: 169, firstMax: 169, secondMin: 254, secondMax: 254 }, // link-local 169.254.0.0/16
{ firstMin: 172, firstMax: 172, secondMin: 16, secondMax: 31 }, // RFC1918 172.16.0.0/12
{ firstMin: 192, firstMax: 192, secondMin: 168, secondMax: 168 }, // RFC1918 192.168.0.0/16
];
function isInIpv4Range(first: number, second: number, range: Ipv4Range): boolean {
return (
first >= range.firstMin &&
first <= range.firstMax &&
second >= range.secondMin &&
second <= range.secondMax
);
}
function isPrivateIpv4(hostname: string): boolean {
const octets = hostname.split(".").map(Number);
if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet))) return false;
const [first, second] = octets;
return PRIVATE_IPV4_RANGES.some((range) => isInIpv4Range(first, second, range));
}
function isSupportedProtocol(protocol: string): boolean {
return protocol === "http:" || protocol === "https:";
}
function isLoopbackHostname(hostname: string): boolean {
return !hostname || hostname === "localhost" || hostname.endsWith(".localhost");
}
function isMulticastDnsHostname(hostname: string): boolean {
return hostname.endsWith(".local");
}
function isIpv6LoopbackOrUnspecified(hostname: string): boolean {
return hostname === "::" || hostname === "::1";
}
function isIpv6UniqueLocal(hostname: string): boolean {
// RFC 4193 Unique Local Addresses: fc00::/7 (prefixes "fc" and "fd").
return hostname.startsWith("fc") || hostname.startsWith("fd");
}
const IPV6_LINK_LOCAL_PATTERN = /^fe[89ab]/;
function isIpv6LinkLocal(hostname: string): boolean {
// RFC 4291 link-local: fe80::/10.
return IPV6_LINK_LOCAL_PATTERN.test(hostname);
}
/** Combines the IPv6-specific non-public checks the caller gates on `isIpv6`. */
function isNonPublicIpv6(hostname: string): boolean {
return (
isIpv6LoopbackOrUnspecified(hostname) ||
isIpv6UniqueLocal(hostname) ||
isIpv6LinkLocal(hostname)
);
}
export function isPublicDisplayBaseUrl(value?: string): boolean {
const normalized = normalizeUrl(value);
if (!normalized) return false;
try {
const parsed = new URL(normalized);
if (!isSupportedProtocol(parsed.protocol)) return false;
const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, "");
if (isLoopbackHostname(hostname)) return false;
if (isMulticastDnsHostname(hostname) || isPrivateIpv4(hostname)) return false;
// IPv6-only checks stay gated on isIpv6 — hostnames like "fdroid.example.com"
// legitimately start with "fd" and must not be misclassified as ULA addresses.
const isIpv6 = hostname.includes(":");
if (isIpv6 && isNonPublicIpv6(hostname)) return false;
return true;
} catch {
return false;
}
}
export function resolveDisplayBaseUrl(envValue?: string, browserOrigin?: string): string {
const configuredUrl = normalizeUrl(envValue);
const currentOrigin = normalizeUrl(browserOrigin);
if (currentOrigin && isPublicDisplayBaseUrl(currentOrigin)) return currentOrigin;
if (configuredUrl && isPublicDisplayBaseUrl(configuredUrl)) return configuredUrl;
return currentOrigin ?? configuredUrl ?? DEFAULT_DISPLAY_BASE_URL;
}
/**
* Returns the public base URL to display in the dashboard.
*
* Resolution chain:
* 1. NEXT_PUBLIC_BASE_URL (env, trimmed + slash-normalized) — wins if set.
* 2. window.location.origin after client mount — when env is unset.
* 3. DEFAULT_DISPLAY_BASE_URL ("http://localhost:20128") — SSR / first render fallback.
* Resolution chain after client mount:
* 1. Public browser origin — proves the current tunnel/domain is reachable.
* 2. Public NEXT_PUBLIC_BASE_URL — keeps a configured public URL when opened locally.
* 3. Current browser origin, configured URL, then localhost as local fallbacks.
*
* DISPLAY ONLY — do NOT use this hook for OAuth `redirect_uri`.
* OAuth callers must read `process.env.NEXT_PUBLIC_BASE_URL` directly to avoid
@@ -29,8 +138,7 @@ export function useDisplayBaseUrl(): string {
const [url, setUrl] = useState<string>(envValue ?? DEFAULT_DISPLAY_BASE_URL);
useEffect(() => {
if (envValue) return;
const origin = normalizeUrl(window.location.origin) ?? DEFAULT_DISPLAY_BASE_URL;
const resolvedUrl = resolveDisplayBaseUrl(envValue ?? undefined, window.location.origin);
// Schedule via queueMicrotask so setState is called inside a callback,
// not synchronously in the effect body (react-hooks/set-state-in-effect).
// The unmounted guard prevents a stale setState on a torn-down root
@@ -38,7 +146,7 @@ export function useDisplayBaseUrl(): string {
// before the microtask fires on the first effect invocation).
let unmounted = false;
queueMicrotask(() => {
if (!unmounted) setUrl(origin);
if (!unmounted) setUrl(resolvedUrl);
});
return () => {
unmounted = true;