feat: move web endpoint into a first-party plugin

This commit is contained in:
LukeGus committed 2026-09-22 12:30:35 -05:00
1 parent 6f0dff58a3
commit 6e058f6e9e
26 files changed
+496 -121

No files matched your search

-162
View File
@@ -1,162 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { WebEndpoint } from "@/types/index";
const isElectron = vi.hoisted(() => vi.fn(() => false));
vi.mock("@/lib/electron", () => ({ isElectron }));
const tunnelPost = vi.hoisted(() => vi.fn());
vi.mock("@/main-axios", () => ({
tunnelApi: { post: tunnelPost },
handleApiError: (error: unknown) => {
throw new Error(`generic: ${String(error)}`);
},
}));
function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
return {
id: "e1",
label: "Proxmox",
scheme: "https",
port: 8006,
path: "/",
access: "tunnel",
render: "external",
bindHost: "0.0.0.0",
...overrides,
};
}
const host = { id: "7", ip: "192.168.1.10" };
let pageHostname = "localhost";
const realLocation = window.location;
const windowOpen = vi.fn();
beforeEach(() => {
pageHostname = "localhost";
isElectron.mockReturnValue(false);
tunnelPost.mockReset();
tunnelPost.mockResolvedValue({ data: { port: 41234 } });
Object.defineProperty(window, "location", {
configurable: true,
get: () => ({ ...realLocation, hostname: pageHostname }),
});
vi.stubGlobal("open", windowOpen);
});
afterEach(() => {
Object.defineProperty(window, "location", {
configurable: true,
value: realLocation,
});
vi.unstubAllGlobals();
windowOpen.mockReset();
});
describe("openWebEndpointTunnel", () => {
it("posts a path relative to the tunnel base, never one starting /ssh", async () => {
// tunnelApi's baseURL already includes /ssh; a leading "/ssh" here would
// resolve to /ssh/ssh/... and 404 on every call. Asserted on the captured
// runtime argument rather than by scanning source, so quoting style and
// indirection cannot fool it.
const { openWebEndpointTunnel } = await import("@/api/web-endpoint-api");
await openWebEndpointTunnel(7, "e1");
expect(tunnelPost.mock.calls[0][0]).not.toMatch(/^\/ssh\//);
expect(tunnelPost).toHaveBeenCalledWith("/tunnel/web-endpoint/open", {
hostId: 7,
endpointId: "e1",
});
});
it("returns the port", async () => {
const { openWebEndpointTunnel } = await import("@/api/web-endpoint-api");
await expect(openWebEndpointTunnel(7, "e1")).resolves.toBe(41234);
});
it("preserves the backend's reason instead of the generic message", async () => {
// 502 is this route's likeliest real failure and carries the actionable
// cause. Collapsing it into "Server error occurred" defeats the
// per-failure-mode messaging entirely.
const axiosError = Object.assign(new Error("Request failed"), {
isAxiosError: true,
response: {
status: 502,
data: { error: "Timed out reaching the endpoint port" },
},
});
tunnelPost.mockRejectedValue(axiosError);
const { openWebEndpointTunnel, WebEndpointTunnelError } =
await import("@/api/web-endpoint-api");
await expect(openWebEndpointTunnel(7, "e1")).rejects.toThrow(
/Timed out reaching the endpoint port/,
);
await expect(openWebEndpointTunnel(7, "e1")).rejects.toBeInstanceOf(
WebEndpointTunnelError,
);
});
it("falls back to the shared handler when the body carries no string reason", async () => {
// A body shaped { error: <object> } would otherwise render as
// "[object Object]" to the user.
tunnelPost.mockRejectedValue(
Object.assign(new Error("boom"), {
isAxiosError: true,
response: { status: 500, data: { error: { nested: true } } },
}),
);
const { openWebEndpointTunnel } = await import("@/api/web-endpoint-api");
await expect(openWebEndpointTunnel(7, "e1")).rejects.toThrow(/generic:/);
});
it("treats a missing port as a failure rather than returning undefined", async () => {
tunnelPost.mockResolvedValue({ data: {} });
const { openWebEndpointTunnel } = await import("@/api/web-endpoint-api");
await expect(openWebEndpointTunnel(7, "e1")).rejects.toThrow(/no port/);
});
});
describe("requireNumericHostId", () => {
it("accepts a saved host id and rejects a quick-connect one", async () => {
const { requireNumericHostId } = await import("@/api/web-endpoint-api");
expect(requireNumericHostId("7")).toBe(7);
for (const bad of ["quick-connect-1", "", "0", "-3", "abc"]) {
expect(() => requireNumericHostId(bad)).toThrow(/saved host/);
}
});
});
/**
* "Open externally" is not the safer path. The cookie jar belongs to the
* browser either way, so a tunnel URL on the host string serving Termix hands
* the tunnelled service this session exactly as an embedded frame would.
*/
describe("openWebEndpointExternally", () => {
it("refuses shared-cookie browser windows before opening a tunnel", async () => {
const { openWebEndpointExternally } =
await import("@/api/web-endpoint-api");
await expect(openWebEndpointExternally(host, endpoint())).rejects.toThrow(
/desktop app/,
);
expect(windowOpen).not.toHaveBeenCalled();
expect(tunnelPost).not.toHaveBeenCalled();
});
it("opens desktop endpoints through the isolated-window bridge", async () => {
isElectron.mockReturnValue(true);
const invoke = vi.fn().mockResolvedValue({ success: true });
Object.defineProperty(window, "electronAPI", {
configurable: true,
value: { invoke },
});
const { openWebEndpointExternally } =
await import("@/api/web-endpoint-api");
await openWebEndpointExternally(host, endpoint({ ignoreCert: true }));
expect(invoke).toHaveBeenCalledWith("open-isolated-web-endpoint", {
url: "https://127.0.0.1:41234/",
ignoreCert: true,
});
expect(windowOpen).not.toHaveBeenCalled();
delete (window as unknown as { electronAPI?: unknown }).electronAPI;
});
});
@@ -1,452 +0,0 @@
import "@testing-library/jest-dom/vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import type { WebEndpoint } from "@/types/index";
import type { Host } from "@/types/ui-types";
const openWebEndpointTunnel = vi.hoisted(() => vi.fn());
const allowInvalidCertificateForOrigin = vi.hoisted(() => vi.fn());
vi.mock("@/api/web-endpoint-api", async (importOriginal) => {
// requireNumericHostId is left as the real implementation -- the "no saved
// host" case exercises the actual validation, not a re-implementation of it
// that could drift.
const actual =
await importOriginal<typeof import("@/api/web-endpoint-api")>();
return {
...actual,
openWebEndpointTunnel: (...args: unknown[]) =>
openWebEndpointTunnel(...args),
allowInvalidCertificateForOrigin: (...args: unknown[]) =>
allowInvalidCertificateForOrigin(...args),
};
});
const electron = vi.hoisted(() => ({ isElectron: vi.fn(() => true) }));
vi.mock("@/lib/electron", () => electron);
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
return {
id: "e1",
label: "Proxmox",
scheme: "https",
port: 8006,
path: "/",
access: "direct",
render: "embedded",
...overrides,
};
}
function host(
overrides: Partial<Host> = {},
endpoints: WebEndpoint[] = [],
): Host {
return {
id: "7",
ip: "192.168.1.10",
webUiConfig: { endpoints },
...overrides,
} as unknown as Host;
}
let pageHostname = "localhost";
const realLocation = window.location;
beforeEach(() => {
Object.defineProperty(HTMLIFrameElement.prototype, "credentialless", {
configurable: true,
writable: true,
value: false,
});
pageHostname = "localhost";
electron.isElectron.mockReturnValue(true);
Object.defineProperty(window, "location", {
configurable: true,
get: () => ({ ...realLocation, hostname: pageHostname }),
});
});
afterEach(() => {
cleanup();
delete (HTMLIFrameElement.prototype as { credentialless?: boolean })
.credentialless;
Object.defineProperty(window, "location", {
configurable: true,
value: realLocation,
});
openWebEndpointTunnel.mockReset();
allowInvalidCertificateForOrigin.mockReset();
});
describe("WebEndpointTab", () => {
it("refuses unsupported environments before creating a tunnel or iframe", async () => {
delete (HTMLIFrameElement.prototype as { credentialless?: boolean })
.credentialless;
const { WebEndpointTab } =
await import("@/features/web-endpoint/WebEndpointTab");
render(
<WebEndpointTab
host={host({}, [endpoint({ access: "tunnel" })])}
endpointId="e1"
/>,
);
await screen.findByText("webEndpoint.isolationUnavailable");
expect(openWebEndpointTunnel).not.toHaveBeenCalled();
expect(document.querySelector("iframe")).toBeNull();
});
it("isolates cookie state and denies parent access, popups and top navigation", async () => {
const { WebEndpointTab } =
await import("@/features/web-endpoint/WebEndpointTab");
render(<WebEndpointTab host={host({}, [endpoint()])} endpointId="e1" />);
const frame = (await screen.findByTitle("Proxmox")) as HTMLIFrameElement;
expect(
(frame as HTMLIFrameElement & { credentialless: boolean }).credentialless,
).toBe(true);
expect(frame.getAttribute("sandbox")).toBe("allow-scripts allow-forms");
expect(frame.getAttribute("referrerpolicy")).toBe("no-referrer");
expect(screen.queryByText("webEndpoint.openExternally")).toBeNull();
});
it("renders an iframe at the direct URL", async () => {
const { WebEndpointTab } =
await import("@/features/web-endpoint/WebEndpointTab");
render(<WebEndpointTab host={host({}, [endpoint()])} endpointId="e1" />);
await waitFor(() =>
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
"src",
"https://192.168.1.10:8006/",
),
);
});
it("opens the tunnel through the numeric host id and frames the loopback URL", async () => {
openWebEndpointTunnel.mockResolvedValue(41234);
const { WebEndpointTab } =
await import("@/features/web-endpoint/WebEndpointTab");
render(
<WebEndpointTab
host={host({}, [endpoint({ access: "tunnel" })])}
endpointId="e1"
/>,
);
await waitFor(() => {
expect(openWebEndpointTunnel).toHaveBeenCalledWith(7, "e1");
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
"src",
"https://127.0.0.1:41234/",
);
});
});
it("registers the certificate allowance for a direct endpoint with ignoreCert", async () => {
const { WebEndpointTab } =
await import("@/features/web-endpoint/WebEndpointTab");
render(
<WebEndpointTab
host={host({}, [endpoint({ ignoreCert: true })])}
endpointId="e1"
/>,
);
await waitFor(() =>
expect(allowInvalidCertificateForOrigin).toHaveBeenCalledWith(
"https://192.168.1.10:8006",
),
);
});
it("reframes at the new port when a reopened tunnel binds a different one", async () => {
openWebEndpointTunnel
.mockResolvedValueOnce(41234)
.mockResolvedValueOnce(51234);
const { WebEndpointTab } =
await import("@/features/web-endpoint/WebEndpointTab");
render(
<WebEndpointTab
host={host({}, [endpoint({ access: "tunnel" })])}
endpointId="e1"
/>,
);
await waitFor(() =>
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
"src",
"https://127.0.0.1:41234/",
),
);
fireEvent.click(screen.getByTitle("webEndpoint.reload"));
await waitFor(() =>
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
"src",
"https://127.0.0.1:51234/",
),
);
expect(openWebEndpointTunnel).toHaveBeenCalledTimes(2);
});
/**
* The case the port-changed test above CANNOT exercise, and the one that
* matters most: a live tunnel returns the SAME port on every open, and a
* direct endpoint's URL never changes at all. If reload just did
* setUrl(resolved), React would bail out of the same-value setState and the
* frame would never remount -- so the button would do nothing in the two
* most common cases. Capturing the DOM node before and after is what catches
* that: a same-value setState never touches the DOM.
*/
it("remounts the frame on reload even when the resolved URL is unchanged", async () => {
openWebEndpointTunnel.mockResolvedValue(41234);
const { WebEndpointTab } =
await import("@/features/web-endpoint/WebEndpointTab");
render(
<WebEndpointTab
host={host({}, [endpoint({ access: "tunnel" })])}
endpointId="e1"
/>,
);
const firstFrame = await screen.findByTitle("Proxmox");
expect(firstFrame).toHaveAttribute("src", "https://127.0.0.1:41234/");
fireEvent.click(screen.getByTitle("webEndpoint.reload"));
await waitFor(() => expect(openWebEndpointTunnel).toHaveBeenCalledTimes(2));
// Polled rather than asserted once: against the defective implementation
// this never becomes true and the test times out -- a real failure, not a
// race.
await waitFor(() => {
expect(screen.getByTitle("Proxmox")).not.toBe(firstFrame);
});
});
it("shows the failure reason when the tunnel cannot open", async () => {
openWebEndpointTunnel.mockRejectedValue(
new Error("Timed out reaching the endpoint port"),
);
const { WebEndpointTab } =
await import("@/features/web-endpoint/WebEndpointTab");
render(
<WebEndpointTab
host={host({}, [endpoint({ access: "tunnel" })])}
endpointId="e1"
/>,
);
await waitFor(() =>
expect(
screen.getByText(/Timed out reaching the endpoint port/),
).toBeInTheDocument(),
);
});
it("shows a plain message when the endpoint id matches nothing on the host", async () => {
const { WebEndpointTab } =
await import("@/features/web-endpoint/WebEndpointTab");
render(<WebEndpointTab host={host({}, [])} endpointId="deleted" />);
await waitFor(() =>
expect(screen.getByText("webEndpoint.notFound")).toBeInTheDocument(),
);
expect(openWebEndpointTunnel).not.toHaveBeenCalled();
expect(document.querySelector("iframe")).toBeNull();
});
it("explains that a tunnel needs a saved host, instead of calling the route", async () => {
const { WebEndpointTab } =
await import("@/features/web-endpoint/WebEndpointTab");
render(
<WebEndpointTab
host={host({ id: "quick-connect-1" }, [endpoint({ access: "tunnel" })])}
endpointId="e1"
/>,
);
await waitFor(() =>
expect(screen.getByText(/requires a saved host/)).toBeInTheDocument(),
);
expect(openWebEndpointTunnel).not.toHaveBeenCalled();
});
it("resolves a direct endpoint normally on the same quick-connect host", async () => {
// The positive case: requireNumericHostId must be consulted only on the
// tunnel branch, or direct endpoints would break on unsaved hosts too.
const { WebEndpointTab } =
await import("@/features/web-endpoint/WebEndpointTab");
render(
<WebEndpointTab
host={host({ id: "quick-connect-1" }, [endpoint({ access: "direct" })])}
endpointId="e1"
/>,
);
await waitFor(() =>
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
"src",
"https://192.168.1.10:8006/",
),
);
expect(openWebEndpointTunnel).not.toHaveBeenCalled();
});
it("frames a tunnel at the OTHER loopback spelling in a web deployment", async () => {
// jsdom serves the page at "localhost". The forward binds where the
// backend runs, so the browser must reach it on this same machine -- but
// NOT at the same host string, or the tunnelled service receives Termix's
// session cookie.
electron.isElectron.mockReturnValue(false);
openWebEndpointTunnel.mockResolvedValue(41234);
const { WebEndpointTab } =
await import("@/features/web-endpoint/WebEndpointTab");
render(
<WebEndpointTab
host={host({}, [endpoint({ access: "tunnel", bindHost: "0.0.0.0" })])}
endpointId="e1"
/>,
);
await waitFor(() =>
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
"src",
"https://127.0.0.1:41234/",
),
);
// The assertion carrying the security property: whatever host the frame
// lands on, it must not be the one serving Termix.
const framed = new URL(
screen.getByTitle("Proxmox").getAttribute("src") as string,
).hostname;
expect(framed).not.toBe(window.location.hostname);
});
it("refuses a loopback-bound tunnel in a browser instead of framing a dead port", async () => {
electron.isElectron.mockReturnValue(false);
const { WebEndpointTab } =
await import("@/features/web-endpoint/WebEndpointTab");
render(
<WebEndpointTab
host={host({}, [endpoint({ access: "tunnel" })])}
endpointId="e1"
/>,
);
await waitFor(() =>
expect(
screen.getByText("webEndpoint.tunnelUnreachableFromBrowser"),
).toBeInTheDocument(),
);
expect(openWebEndpointTunnel).not.toHaveBeenCalled();
expect(document.querySelector("iframe")).toBeNull();
});
it("refuses when the page host has no separate loopback spelling", async () => {
electron.isElectron.mockReturnValue(false);
pageHostname = "termix.example.com";
const { WebEndpointTab } =
await import("@/features/web-endpoint/WebEndpointTab");
render(
<WebEndpointTab
host={host({}, [endpoint({ access: "tunnel", bindHost: "0.0.0.0" })])}
endpointId="e1"
/>,
);
await waitFor(() =>
expect(
screen.getByText("webEndpoint.tunnelSharesSessionCookie"),
).toBeInTheDocument(),
);
// Refusing after opening would still have bound the port and still have
// leaked on the first frame load.
expect(openWebEndpointTunnel).not.toHaveBeenCalled();
expect(document.querySelector("iframe")).toBeNull();
});
/**
* The leak the tunnel guard did not cover: a DIRECT endpoint points at the
* host's own address, so when that host is the one serving Termix the frame
* is same-site with Termix and the browser attaches the `jwt` cookie. A
* different port is not a different cookie key.
*/
it("refuses a direct endpoint on the same host that serves Termix", async () => {
electron.isElectron.mockReturnValue(false);
pageHostname = "termix.example";
const { WebEndpointTab } =
await import("@/features/web-endpoint/WebEndpointTab");
render(
<WebEndpointTab
host={host({ ip: "termix.example" }, [
endpoint({ access: "direct", port: 8443 }),
])}
endpointId="e1"
/>,
);
await waitFor(() =>
expect(
screen.getByText("webEndpoint.directSharesSessionCookie"),
).toBeInTheDocument(),
);
// Refusing after framing would have leaked on the very first request.
expect(document.querySelector("iframe")).toBeNull();
});
it("refuses a direct endpoint on a sub domain of the page host", async () => {
electron.isElectron.mockReturnValue(false);
pageHostname = "termix.example.com";
const { WebEndpointTab } =
await import("@/features/web-endpoint/WebEndpointTab");
render(
<WebEndpointTab
host={host({ ip: "ui.termix.example.com" }, [
endpoint({ access: "direct" }),
])}
endpointId="e1"
/>,
);
await waitFor(() =>
expect(
screen.getByText("webEndpoint.directSharesSessionCookie"),
).toBeInTheDocument(),
);
expect(document.querySelector("iframe")).toBeNull();
});
it("still frames a direct same-host endpoint on the desktop, whose jar has no jwt", async () => {
electron.isElectron.mockReturnValue(true);
pageHostname = "termix.example";
const { WebEndpointTab } =
await import("@/features/web-endpoint/WebEndpointTab");
render(
<WebEndpointTab
host={host({ ip: "termix.example" }, [
endpoint({ access: "direct", port: 8443 }),
])}
endpointId="e1"
/>,
);
await waitFor(() =>
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
"src",
"https://termix.example:8443/",
),
);
});
it("still frames a DIRECT endpoint on a hostname that refuses tunnels", async () => {
electron.isElectron.mockReturnValue(false);
pageHostname = "termix.example.com";
const { WebEndpointTab } =
await import("@/features/web-endpoint/WebEndpointTab");
render(<WebEndpointTab host={host({}, [endpoint()])} endpointId="e1" />);
await waitFor(() =>
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
"src",
"https://192.168.1.10:8006/",
),
);
});
});
-367
View File
@@ -1,367 +0,0 @@
import { describe, expect, it } from "vitest";
import {
currentTunnelHost,
resolveWebEndpointUrl,
separatedTunnelHost,
sharesCookieSiteWithPage,
webEndpointRefusalReason,
} from "@/lib/web-endpoint-url";
import type { WebEndpoint } from "@/types/index";
function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
return {
id: "e1",
label: "Proxmox",
scheme: "https",
port: 8006,
access: "direct",
render: "external",
...overrides,
};
}
describe("resolveWebEndpointUrl", () => {
it("builds a direct URL at the host address", () => {
expect(
resolveWebEndpointUrl({
hostAddress: "192.168.1.10",
endpoint: endpoint(),
}),
).toBe("https://192.168.1.10:8006/");
});
it("builds a direct URL for a hostname", () => {
expect(
resolveWebEndpointUrl({
hostAddress: "nas.local",
endpoint: endpoint({ port: 443 }),
}),
).toBe("https://nas.local:443/");
});
it("uses the local port for tunnel access, dropping the endpoint's own port", () => {
expect(
resolveWebEndpointUrl({
hostAddress: "192.168.1.10",
endpoint: endpoint({ access: "tunnel", scheme: "http", port: 8006 }),
localPort: 41234,
}),
).toBe("http://127.0.0.1:41234/");
});
it("defaults a missing path to /", () => {
expect(
resolveWebEndpointUrl({
hostAddress: "10.0.0.5",
endpoint: endpoint({ scheme: "http", port: 80, path: undefined }),
}),
).toBe("http://10.0.0.5:80/");
});
it("keeps an explicit path and its query", () => {
expect(
resolveWebEndpointUrl({
hostAddress: "10.0.0.5",
endpoint: endpoint({
scheme: "http",
port: 9000,
path: "/admin?tab=1",
}),
}),
).toBe("http://10.0.0.5:9000/admin?tab=1");
});
it("throws when tunnel access has no local port", () => {
expect(() =>
resolveWebEndpointUrl({
hostAddress: "10.0.0.5",
endpoint: endpoint({ access: "tunnel" }),
}),
).toThrow(/local port/i);
});
it("brackets a bare IPv6 host address", () => {
expect(
resolveWebEndpointUrl({
hostAddress: "fd00::1",
endpoint: endpoint({ scheme: "http", port: 8080 }),
}),
).toBe("http://[fd00::1]:8080/");
});
it("brackets a bare IPv6 tunnel host", () => {
expect(
resolveWebEndpointUrl({
hostAddress: "10.0.0.5",
endpoint: endpoint({ access: "tunnel", scheme: "http" }),
localPort: 41234,
tunnelHost: "fd00::1",
}),
).toBe("http://[fd00::1]:41234/");
});
it("uses an explicit tunnel host when given", () => {
expect(
resolveWebEndpointUrl({
hostAddress: "10.0.0.5",
endpoint: endpoint({ access: "tunnel", scheme: "http" }),
localPort: 41234,
tunnelHost: "termix.lan",
}),
).toBe("http://termix.lan:41234/");
});
});
/**
* Cookies are keyed by host and IGNORE the port, and SameSite computes "site"
* as scheme + registrable domain -- also ignoring the port. So a forward
* reached at the very host string serving Termix is same-site with Termix:
* the browser attaches the jwt cookie to every request the framed
* third-party server receives, and a Set-Cookie from that server lands in the
* jar Termix's own API calls read from (both auth middlewares prefer the
* cookie over the Authorization header).
*
* Both directions were reproduced against a real SSH forward. separatedTunnelHost
* is the whole mitigation: a host string that reaches the same machine but is
* a DIFFERENT cookie-jar key.
*/
describe("separatedTunnelHost", () => {
it("maps each loopback spelling to a different one", () => {
expect(separatedTunnelHost("localhost")).toBe("127.0.0.1");
expect(separatedTunnelHost("127.0.0.1")).toBe("localhost");
expect(separatedTunnelHost("::1")).toBe("127.0.0.1");
expect(separatedTunnelHost("[::1]")).toBe("127.0.0.1");
});
it("never returns the host it was given, which would be no separation", () => {
for (const host of ["localhost", "127.0.0.1", "::1", "[::1]"]) {
expect(separatedTunnelHost(host)).not.toBe(host);
}
});
it("is case- and whitespace-insensitive", () => {
expect(separatedTunnelHost(" LOCALHOST ")).toBe("127.0.0.1");
});
it("returns null for a host with no alias, rather than guessing", () => {
// A same-registrable-domain alias would NOT be safe: the target could
// answer Set-Cookie with Domain=example.com and reach Termix anyway.
// Only loopback literals qualify -- they cannot carry a Domain.
expect(separatedTunnelHost("termix.example.com")).toBeNull();
expect(separatedTunnelHost("192.168.1.50")).toBeNull();
expect(separatedTunnelHost("")).toBeNull();
});
});
describe("currentTunnelHost", () => {
it("is loopback on the desktop, whose API base is localhost", () => {
// Two different cookie-jar keys. Do not "simplify" either to match.
expect(currentTunnelHost(true)).toBe("127.0.0.1");
});
it("swaps to the other loopback spelling when Termix is served at localhost", () => {
expect(currentTunnelHost(false, "localhost")).toBe("127.0.0.1");
});
it("swaps the other way when Termix is served at 127.0.0.1", () => {
expect(currentTunnelHost(false, "127.0.0.1")).toBe("localhost");
});
it("has no answer for a page host with no loopback alias", () => {
expect(currentTunnelHost(false, "termix.example.com")).toBeNull();
expect(currentTunnelHost(false, "192.168.1.50")).toBeNull();
});
});
/**
* The cookie leak is not tunnel-only. A DIRECT endpoint points at the host's
* own address, and when that host is the same one serving Termix in a browser
* (a different port is not a different cookie key), the framed third-party
* service receives Termix's `jwt` -- and can set one back. So the refusal has
* to cover direct access too, using the endpoint's target host rather than a
* bind address.
*/
describe("sharesCookieSiteWithPage", () => {
it("is true for the exact same host, because cookies ignore the port", () => {
expect(sharesCookieSiteWithPage("termix.example", "termix.example")).toBe(
true,
);
});
it("is true for a parent or sub domain, which a Domain-scoped cookie crosses", () => {
expect(
sharesCookieSiteWithPage("app.termix.example", "termix.example"),
).toBe(true);
expect(
sharesCookieSiteWithPage("termix.example", "app.termix.example"),
).toBe(true);
});
it("is false for a genuinely different host", () => {
expect(sharesCookieSiteWithPage("192.168.1.10", "termix.example")).toBe(
false,
);
expect(sharesCookieSiteWithPage("nas.local", "termix.example")).toBe(false);
});
it("is case- and whitespace-insensitive and ignores a trailing dot", () => {
expect(
sharesCookieSiteWithPage(" TERMIX.example. ", "termix.example"),
).toBe(true);
});
it("does not treat a shared suffix off a label boundary as same-site", () => {
// "eviltermix.example" ends with "termix.example" as a string but not on a
// dot boundary -- treating it as same-site would let an attacker pick such
// a name to dodge the guard, so it must be false.
expect(
sharesCookieSiteWithPage("eviltermix.example", "termix.example"),
).toBe(false);
});
it("is false for blank input rather than guessing", () => {
expect(sharesCookieSiteWithPage("", "termix.example")).toBe(false);
expect(sharesCookieSiteWithPage("termix.example", "")).toBe(false);
});
});
describe("webEndpointRefusalReason", () => {
const tunnel = (bindHost?: string) =>
endpoint({ access: "tunnel", bindHost }) as Pick<
WebEndpoint,
"access" | "bindHost"
>;
const direct = () =>
endpoint({ access: "direct" }) as Pick<WebEndpoint, "access" | "bindHost">;
it("refuses a direct endpoint on the same host that serves Termix", () => {
// https://termix.example:8443 receives Termix's jwt: same host, and the
// port is not part of the cookie key.
expect(
webEndpointRefusalReason(
direct(),
false,
"termix.example",
"termix.example",
),
).toBe("direct-shares-session-cookie");
});
it("refuses a direct endpoint on a parent or sub domain of the page host", () => {
expect(
webEndpointRefusalReason(
direct(),
false,
"termix.example.com",
"app.termix.example.com",
),
).toBe("direct-shares-session-cookie");
expect(
webEndpointRefusalReason(
direct(),
false,
"app.termix.example.com",
"termix.example.com",
),
).toBe("direct-shares-session-cookie");
});
it("allows a direct endpoint on a genuinely different host", () => {
expect(
webEndpointRefusalReason(
direct(),
false,
"192.168.1.10",
"termix.example",
),
).toBeNull();
expect(
webEndpointRefusalReason(direct(), false, "nas.local", "termix.example"),
).toBeNull();
});
it("allows a direct same-host endpoint on the desktop, whose jar has no jwt", () => {
expect(
webEndpointRefusalReason(
direct(),
true,
"termix.example",
"termix.example",
),
).toBeNull();
});
it("passes anything on the desktop", () => {
expect(
webEndpointRefusalReason(tunnel(), true, undefined, "localhost"),
).toBeNull();
expect(
webEndpointRefusalReason(tunnel("0.0.0.0"), true, undefined, "localhost"),
).toBeNull();
});
it("refuses a loopback bind a remote backend would strand", () => {
expect(
webEndpointRefusalReason(tunnel(), false, undefined, "localhost"),
).toBe("loopback-bind-on-remote-backend");
expect(
webEndpointRefusalReason(
tunnel("127.0.0.1"),
false,
undefined,
"localhost",
),
).toBe("loopback-bind-on-remote-backend");
});
it("allows an exposed bind when the page host has a loopback alias", () => {
expect(
webEndpointRefusalReason(
tunnel("0.0.0.0"),
false,
undefined,
"localhost",
),
).toBeNull();
expect(
webEndpointRefusalReason(
tunnel("0.0.0.0"),
false,
undefined,
"127.0.0.1",
),
).toBeNull();
});
it("refuses when the tunnel URL would share Termix's cookie jar", () => {
expect(
webEndpointRefusalReason(
tunnel("0.0.0.0"),
false,
undefined,
"termix.example.com",
),
).toBe("shares-session-cookie-with-termix");
expect(
webEndpointRefusalReason(
tunnel("0.0.0.0"),
false,
undefined,
"192.168.1.50",
),
).toBe("shares-session-cookie-with-termix");
});
it("reports the reachability problem first when both apply", () => {
// The bind address is what the user has to change either way, and it is
// the more specific instruction.
expect(
webEndpointRefusalReason(
tunnel(),
false,
undefined,
"termix.example.com",
),
).toBe("loopback-bind-on-remote-backend");
});
});
@@ -1,137 +0,0 @@
import { describe, expect, it } from "vitest";
import {
findInvalidWebEndpoint,
isWebEndpointPathValid,
isWebEndpointPortValid,
webEndpointErrorKey,
webEndpointRowError,
} from "@/lib/web-endpoint-validation";
import { normalizeWebEndpoints } from "../../../backend/database/routes/host-web-endpoints.js";
import type { WebEndpoint } from "@/types/index";
function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
return {
id: "e1",
label: "Proxmox",
scheme: "https",
port: 8006,
path: "/",
access: "direct",
render: "external",
...overrides,
};
}
describe("isWebEndpointPortValid", () => {
it("accepts the whole legal range and nothing else", () => {
expect(isWebEndpointPortValid(1)).toBe(true);
expect(isWebEndpointPortValid(65535)).toBe(true);
for (const bad of [0, -1, 65536, 8.5, "8006", null, undefined, NaN]) {
expect(isWebEndpointPortValid(bad)).toBe(false);
}
});
});
describe("webEndpointRowError", () => {
it("returns null for a row the normalizer would keep", () => {
expect(webEndpointRowError(endpoint())).toBeNull();
});
it("reports a blank label before anything else", () => {
// Both label and port are wrong; the label is the likelier oversight.
expect(webEndpointRowError(endpoint({ label: " ", port: 0 }))).toBe(
"label",
);
});
it("reports a bad port and a bad path", () => {
expect(webEndpointRowError(endpoint({ port: 0 }))).toBe("port");
expect(webEndpointRowError(endpoint({ path: "//evil.example" }))).toBe(
"path",
);
});
});
describe("findInvalidWebEndpoint", () => {
it("returns null when every row would survive", () => {
expect(
findInvalidWebEndpoint([
endpoint(),
endpoint({ id: "e2", label: "NAS" }),
]),
).toBeNull();
});
it("names the offending row by index", () => {
expect(
findInvalidWebEndpoint([endpoint(), endpoint({ id: "e2", label: "" })]),
).toEqual({ index: 1, error: "label" });
});
it("reports duplicate labels, which the picker cannot tell apart", () => {
expect(
findInvalidWebEndpoint([
endpoint(),
endpoint({ id: "e2", label: "proxmox" }),
]),
).toEqual({ index: 1, error: "duplicateLabel" });
});
it("tolerates a missing list", () => {
expect(findInvalidWebEndpoint(undefined)).toBeNull();
expect(findInvalidWebEndpoint(null)).toBeNull();
});
});
describe("webEndpointErrorKey", () => {
it("maps every error to a distinct i18n key", () => {
const keys = (["label", "duplicateLabel", "port", "path"] as const).map(
webEndpointErrorKey,
);
expect(new Set(keys).size).toBe(keys.length);
for (const key of keys) expect(key).toMatch(/^hosts\./);
});
});
/**
* The editor validator and the backend normalizer are two implementations of
* overlapping rules, in two layers that cannot share code (the renderer does
* not import backend route modules). Nothing but this describe block stops
* them drifting -- and drift here is invisible in the worst way: the editor
* accepts a row, the normalizer silently drops it, and the user's endpoint
* disappears on reload with no error anywhere.
*/
describe("the editor agrees with the normalizer", () => {
const PATHS = [
"/",
"",
"/admin",
"admin",
"/admin?tab=1",
"/a b",
"//evil.example",
"http://evil.example/x",
"/x:y",
"\t//evil.example",
"\n//evil.example",
"/a\tb",
`/a${String.fromCharCode(0x7f)}b`,
"/back\\slash",
];
it.each(PATHS)("agrees on path %j", (path) => {
const editorAccepts = isWebEndpointPathValid(path);
const normalizerKeeps =
normalizeWebEndpoints([{ ...endpoint(), path }]).length === 1;
expect(editorAccepts).toBe(normalizerKeeps);
});
const PORTS = [1, 80, 8006, 65535, 0, -1, 65536, 8.5];
it.each(PORTS)("agrees on port %j", (port) => {
const editorAccepts = isWebEndpointPortValid(port);
const normalizerKeeps =
normalizeWebEndpoints([{ ...endpoint(), port }]).length === 1;
expect(editorAccepts).toBe(normalizerKeeps);
});
});