mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 05:45:04 +03:00
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:
@@ -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", "");
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user