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

@@ -2,7 +2,11 @@
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_DISPLAY_BASE_URL } from "../useDisplayBaseUrl";
import {
DEFAULT_DISPLAY_BASE_URL,
isPublicDisplayBaseUrl,
resolveDisplayBaseUrl,
} from "../useDisplayBaseUrl";
const cleanupCallbacks: Array<() => void> = [];
@@ -64,6 +68,108 @@ describe("useDisplayBaseUrl", () => {
);
});
it("classifies public domains separately from local and private addresses", () => {
expect(isPublicDisplayBaseUrl("https://api.example.com")).toBe(true);
expect(isPublicDisplayBaseUrl("http://localhost:20128")).toBe(false);
expect(isPublicDisplayBaseUrl("http://192.168.1.25:20128")).toBe(false);
expect(isPublicDisplayBaseUrl("http://100.88.4.55:20128")).toBe(false);
expect(isPublicDisplayBaseUrl("http://[::1]:20128")).toBe(false);
});
it("classifies IPv4 private ranges at their exact boundaries", () => {
const cases: Array<[host: string, expectedPublic: boolean]> = [
// 0.0.0.0/8 ("this" network) vs just above it
["0.0.0.1", false],
["1.0.0.1", true],
// RFC1918 10.0.0.0/8 vs just outside
["9.255.255.255", true],
["10.0.0.1", false],
["11.0.0.0", true],
// loopback 127.0.0.0/8 vs just outside
["126.255.255.255", true],
["127.0.0.1", false],
["128.0.0.0", true],
// multicast/reserved 224.0.0.0+ vs just below
["223.255.255.255", true],
["224.0.0.1", false],
// CGNAT RFC6598 100.64.0.0/10 vs just outside
["100.63.255.255", true],
["100.64.0.0", false],
["100.127.255.255", false],
["100.128.0.0", true],
// link-local 169.254.0.0/16 vs just outside
["169.253.255.255", true],
["169.254.0.1", false],
["169.255.0.0", true],
// RFC1918 172.16.0.0/12 vs just outside
["172.15.255.255", true],
["172.16.0.0", false],
["172.31.255.255", false],
["172.32.0.0", true],
// RFC1918 192.168.0.0/16 vs just outside
["192.167.255.255", true],
["192.168.0.1", false],
["192.169.0.0", true],
];
for (const [host, expectedPublic] of cases) {
expect(isPublicDisplayBaseUrl(`http://${host}:20128`)).toBe(expectedPublic);
}
});
it("classifies IPv6 special ranges while keeping the check scoped to actual IPv6 hosts", () => {
expect(isPublicDisplayBaseUrl("http://[::]:20128")).toBe(false);
expect(isPublicDisplayBaseUrl("http://[::1]:20128")).toBe(false);
expect(isPublicDisplayBaseUrl("http://[fc00::1]:20128")).toBe(false);
expect(isPublicDisplayBaseUrl("http://[fd12::1]:20128")).toBe(false);
expect(isPublicDisplayBaseUrl("http://[fe80::1]:20128")).toBe(false);
expect(isPublicDisplayBaseUrl("http://[2001:db8::1]:20128")).toBe(true);
// A hostname that merely STARTS WITH "fd"/"fc" must stay public — the ULA/
// link-local checks are IPv6-only and must not leak into hostname matching.
expect(isPublicDisplayBaseUrl("http://fdroid.example.com:20128")).toBe(true);
expect(isPublicDisplayBaseUrl("http://fcbar.example.com:20128")).toBe(true);
});
it("keeps a configured public URL when the browser is on a local address", () => {
expect(resolveDisplayBaseUrl("https://api.example.com/", "http://localhost:20128")).toBe(
"https://api.example.com"
);
});
it("prefers the currently reachable public origin over another configured URL", () => {
expect(resolveDisplayBaseUrl("https://old.example.com", "https://api.example.com/")).toBe(
"https://api.example.com"
);
});
it("prefers a public browser origin over a loopback build-time value", async () => {
vi.stubEnv("NEXT_PUBLIC_BASE_URL", "http://localhost:20128");
vi.stubGlobal("location", { origin: "https://api.example.com" });
const { useDisplayBaseUrl } = await import("../useDisplayBaseUrl");
const container = makeContainer();
const root = createRoot(container);
function C() {
const url = useDisplayBaseUrl();
return <span data-testid="value">{url}</span>;
}
act(() => {
root.render(<C />);
});
expect(container.querySelector('[data-testid="value"]')?.textContent).toBe(
DEFAULT_DISPLAY_BASE_URL
);
await act(async () => {});
expect(container.querySelector('[data-testid="value"]')?.textContent).toBe(
"https://api.example.com"
);
});
it("returns DEFAULT_DISPLAY_BASE_URL on first render and origin after mount when env unset", async () => {
vi.stubEnv("NEXT_PUBLIC_BASE_URL", "");

View File

@@ -1,3 +1,8 @@
// Shared Hooks - Export all
export { useTheme } from "./useTheme";
export { useDisplayBaseUrl, DEFAULT_DISPLAY_BASE_URL } from "./useDisplayBaseUrl";
export {
useDisplayBaseUrl,
DEFAULT_DISPLAY_BASE_URL,
isPublicDisplayBaseUrl,
resolveDisplayBaseUrl,
} from "./useDisplayBaseUrl";

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;