mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: move web endpoint into a first-party plugin
This commit is contained in:
1 parent
6f0dff58a3
commit
6e058f6e9e
26 files changed
+496
-121
No files matched your search
@@ -1,211 +0,0 @@
|
||||
import type { WebEndpoint } from "@/types/index";
|
||||
|
||||
/**
|
||||
* Builds the URL a web endpoint opens at.
|
||||
*
|
||||
* Direct access points at the host's own address. Tunnel access points at a
|
||||
* port the backend has forwarded to the endpoint's port on the host -- so the
|
||||
* endpoint's own port is deliberately absent from a tunnel URL.
|
||||
*
|
||||
* Expects an endpoint whose `path` has already been normalized by
|
||||
* `normalizeWebEndpoints` (it always begins with "/"); a raw unvalidated path
|
||||
* would produce a malformed URL.
|
||||
*/
|
||||
export function resolveWebEndpointUrl({
|
||||
hostAddress,
|
||||
endpoint,
|
||||
localPort,
|
||||
tunnelHost,
|
||||
}: {
|
||||
hostAddress: string;
|
||||
endpoint: WebEndpoint;
|
||||
localPort?: number;
|
||||
/**
|
||||
* Where the caller can reach the forward the backend bound. Must never be
|
||||
* the host string the browser is currently reaching Termix at -- see
|
||||
* `separatedTunnelHost`. Defaults to loopback.
|
||||
*/
|
||||
tunnelHost?: string;
|
||||
}): string {
|
||||
const path = endpoint.path && endpoint.path.length > 0 ? endpoint.path : "/";
|
||||
|
||||
if (endpoint.access === "tunnel") {
|
||||
if (!localPort) {
|
||||
throw new Error("A tunnel endpoint needs a local port to resolve a URL");
|
||||
}
|
||||
const authority = bracketIfIpv6(tunnelHost?.trim() || "127.0.0.1");
|
||||
return `${endpoint.scheme}://${authority}:${localPort}${path}`;
|
||||
}
|
||||
|
||||
return `${endpoint.scheme}://${bracketIfIpv6(hostAddress)}:${endpoint.port}${path}`;
|
||||
}
|
||||
|
||||
/** A bare IPv6 literal has to be bracketed to be a legal URL authority. */
|
||||
function bracketIfIpv6(host: string): string {
|
||||
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
||||
}
|
||||
|
||||
/** Host spellings that mean "this machine". */
|
||||
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
|
||||
|
||||
/**
|
||||
* Each loopback spelling mapped to a DIFFERENT one that reaches the same
|
||||
* machine. Being a different string is the entire point.
|
||||
*/
|
||||
const LOOPBACK_ALIASES: Record<string, string> = {
|
||||
localhost: "127.0.0.1",
|
||||
"127.0.0.1": "localhost",
|
||||
"::1": "127.0.0.1",
|
||||
"[::1]": "127.0.0.1",
|
||||
};
|
||||
|
||||
/**
|
||||
* A host string that reaches the same machine as `pageHost` but is a
|
||||
* different key in the browser's cookie jar -- or null when none can be
|
||||
* derived.
|
||||
*
|
||||
* This is a security mitigation, not a cosmetic choice. 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, and two things
|
||||
* follow:
|
||||
*
|
||||
* 1. the browser attaches Termix's `jwt` cookie to every request the framed
|
||||
* third-party server sees, including its access log; and
|
||||
* 2. a `Set-Cookie: jwt=...` from that server lands in the jar Termix's own
|
||||
* API calls read from -- and both auth middlewares read the cookie
|
||||
* BEFORE the Authorization header, so it decides who Termix acts as.
|
||||
*
|
||||
* Both directions were reproduced against a real SSH forward.
|
||||
*
|
||||
* Only loopback literals get an alias. A same-registrable-domain name would
|
||||
* not be safe even though it is a different host: the target could answer
|
||||
* `Set-Cookie: jwt=...; Domain=example.com`, which reaches Termix anyway.
|
||||
* Loopback literals cannot carry a Domain attribute, so they are the only
|
||||
* spellings that close both directions.
|
||||
*/
|
||||
export function separatedTunnelHost(pageHost: string): string | null {
|
||||
return LOOPBACK_ALIASES[pageHost.trim().toLowerCase()] ?? null;
|
||||
}
|
||||
|
||||
/** Lowercase, trim, drop surrounding IPv6 brackets and a single trailing dot. */
|
||||
function normalizeHost(host: string): string {
|
||||
let h = host.trim().toLowerCase();
|
||||
if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
|
||||
if (h.endsWith(".")) h = h.slice(0, -1);
|
||||
return h;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a browser would attach Termix's `jwt` cookie to a request for
|
||||
* `targetHost` while the page is served from `pageHost` -- or accept a
|
||||
* `Set-Cookie` from `targetHost` into the jar Termix's own API reads.
|
||||
*
|
||||
* The web client sets `jwt` host-only (no `Domain`), so the port is irrelevant
|
||||
* and the disclosure direction is exactly an equal host. The reverse direction
|
||||
* -- the target answering `Set-Cookie: jwt=...; Domain=<parent>` -- reaches
|
||||
* Termix whenever the two share a domain, so a parent/sub relationship counts
|
||||
* too. The suffix check is anchored on a dot boundary so `eviltermix.example`
|
||||
* is not treated as same-site with `termix.example`.
|
||||
*
|
||||
* A precise registrable-domain (eTLD+1) test would also catch sibling
|
||||
* subdomains under a shared parent, but needs a public-suffix list this app
|
||||
* does not bundle; the exact/parent/sub check closes the reported same-host
|
||||
* case only. This is defense in depth, not cookie isolation: embedded frames
|
||||
* must use credentialless plus an opaque sandbox origin, including redirects.
|
||||
*/
|
||||
export function sharesCookieSiteWithPage(
|
||||
targetHost: string,
|
||||
pageHost: string,
|
||||
): boolean {
|
||||
const t = normalizeHost(targetHost);
|
||||
const p = normalizeHost(pageHost);
|
||||
if (!t || !p) return false;
|
||||
if (t === p) return true;
|
||||
return t.endsWith("." + p) || p.endsWith("." + t);
|
||||
}
|
||||
|
||||
function defaultPageHost(): string {
|
||||
return typeof window === "undefined" ? "127.0.0.1" : window.location.hostname;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a web endpoint must not be opened from this client.
|
||||
*
|
||||
* `loopback-bind-on-remote-backend` -- the forward binds where the backend
|
||||
* runs. In a browser that is the server, so a loopback bind answers only to
|
||||
* the server itself: the open succeeds, the port exists, and the browser
|
||||
* still gets nothing.
|
||||
*
|
||||
* `shares-session-cookie-with-termix` -- the tunnel URL would resolve to the
|
||||
* same host string the page is served from, handing Termix's session token to
|
||||
* the tunnelled service. See `separatedTunnelHost`.
|
||||
*
|
||||
* `direct-shares-session-cookie` -- the same leak without a tunnel: a direct
|
||||
* endpoint's target host shares Termix's cookie site, so the browser attaches
|
||||
* the `jwt` to it (and accepts one back). A different port is not a different
|
||||
* cookie key. See `sharesCookieSiteWithPage`.
|
||||
*/
|
||||
export type WebEndpointRefusalReason =
|
||||
| "loopback-bind-on-remote-backend"
|
||||
| "shares-session-cookie-with-termix"
|
||||
| "direct-shares-session-cookie";
|
||||
|
||||
/**
|
||||
* Why a web endpoint must not be opened from this client, or null when it may.
|
||||
*
|
||||
* Covers both access modes. A tunnel is refused when its forward would either
|
||||
* be unreachable (loopback bind on a remote backend) or resolve to the page's
|
||||
* own host string (`shares-session-cookie-with-termix`). A DIRECT endpoint is
|
||||
* refused when its target host shares Termix's cookie site
|
||||
* (`direct-shares-session-cookie`) -- the same leak, reached without a tunnel:
|
||||
* cookies ignore the port, so a UI on the very host serving Termix receives
|
||||
* the session. `hostAddress` is the direct endpoint's target host and is
|
||||
* ignored for tunnels.
|
||||
*
|
||||
* The desktop is exempt: its session is Bearer-only with no `jwt` in the jar,
|
||||
* and its API base ("localhost") is already separated from where tunnels
|
||||
* resolve ("127.0.0.1").
|
||||
*/
|
||||
export function webEndpointRefusalReason(
|
||||
endpoint: Pick<WebEndpoint, "access" | "bindHost">,
|
||||
runningInElectron: boolean,
|
||||
hostAddress: string | undefined,
|
||||
pageHost: string = defaultPageHost(),
|
||||
): WebEndpointRefusalReason | null {
|
||||
if (runningInElectron) return null;
|
||||
|
||||
if (endpoint.access === "direct") {
|
||||
if (hostAddress && sharesCookieSiteWithPage(hostAddress, pageHost)) {
|
||||
return "direct-shares-session-cookie";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (endpoint.access !== "tunnel") return null;
|
||||
|
||||
const bindHost = (endpoint.bindHost ?? "").trim().toLowerCase();
|
||||
// Reported first when both apply: the bind address is what the user has to
|
||||
// change either way, and it is the more specific instruction.
|
||||
if (!bindHost || LOOPBACK_HOSTS.has(bindHost)) {
|
||||
return "loopback-bind-on-remote-backend";
|
||||
}
|
||||
|
||||
if (!separatedTunnelHost(pageHost)) {
|
||||
return "shares-session-cookie-with-termix";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where this client should reach a forward the backend bound, or null when no
|
||||
* host string is both reachable and safe -- in which case
|
||||
* `unreachableTunnelReason` explains why and the caller must not open.
|
||||
*/
|
||||
export function currentTunnelHost(
|
||||
runningInElectron: boolean,
|
||||
pageHost: string = defaultPageHost(),
|
||||
): string | null {
|
||||
if (runningInElectron) return "127.0.0.1";
|
||||
return separatedTunnelHost(pageHost);
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import type { WebEndpoint } from "@/types/index";
|
||||
|
||||
/**
|
||||
* Editor-side validation for web endpoint rows.
|
||||
*
|
||||
* The authority on what a stored endpoint may look like is
|
||||
* `normalizeWebEndpoints` in
|
||||
* src/backend/database/routes/host-web-endpoints.ts, and it DROPS any row it
|
||||
* refuses rather than reporting it -- so without a check here the user adds a
|
||||
* row, saves, sees no error, and finds the endpoint gone on reload.
|
||||
*
|
||||
* This deliberately does NOT restate that module's full rule set, and does not
|
||||
* import it: the renderer does not import backend route modules (the same
|
||||
* reason MAX_WEB_ENDPOINTS lives in src/types rather than beside the
|
||||
* validator). It covers only the conditions a user can reach by typing.
|
||||
* src/ui/tests/lib/web-endpoint-validation.test.ts runs both implementations
|
||||
* over the same samples so the two cannot drift apart silently.
|
||||
*/
|
||||
|
||||
export const MIN_WEB_ENDPOINT_PORT = 1;
|
||||
export const MAX_WEB_ENDPOINT_PORT = 65535;
|
||||
|
||||
export type WebEndpointFieldError =
|
||||
"label" | "duplicateLabel" | "port" | "path";
|
||||
|
||||
const ERROR_KEYS: Record<WebEndpointFieldError, string> = {
|
||||
label: "hosts.webUiErrorLabelRequired",
|
||||
duplicateLabel: "hosts.webUiErrorLabelDuplicate",
|
||||
port: "hosts.webUiErrorPortRange",
|
||||
path: "hosts.webUiErrorPathInvalid",
|
||||
};
|
||||
|
||||
/**
|
||||
* The i18n key for a field error. Every key takes a `row` interpolation so the
|
||||
* message can say which endpoint is at fault.
|
||||
*/
|
||||
export function webEndpointErrorKey(error: WebEndpointFieldError): string {
|
||||
return ERROR_KEYS[error];
|
||||
}
|
||||
|
||||
export function isWebEndpointPortValid(port: unknown): boolean {
|
||||
return (
|
||||
typeof port === "number" &&
|
||||
Number.isInteger(port) &&
|
||||
port >= MIN_WEB_ENDPOINT_PORT &&
|
||||
port <= MAX_WEB_ENDPOINT_PORT
|
||||
);
|
||||
}
|
||||
|
||||
function hasControlCharacter(value: string): boolean {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index);
|
||||
if (code <= 0x1f || code === 0x7f) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if the normalizer would accept this path. An empty or absent path is
|
||||
* fine -- the normalizer defaults it to "/" -- and so is a path with no
|
||||
* leading slash, which the normalizer coerces.
|
||||
*/
|
||||
export function isWebEndpointPathValid(
|
||||
raw: string | undefined | null,
|
||||
): boolean {
|
||||
if (raw === undefined || raw === null || raw === "") return true;
|
||||
if (hasControlCharacter(raw)) return false;
|
||||
|
||||
const path = raw.startsWith("/") ? raw : `/${raw}`;
|
||||
|
||||
// A protocol-relative or absolute path would send the iframe or the link
|
||||
// somewhere other than the endpoint.
|
||||
if (path.startsWith("//")) return false;
|
||||
if (/^\/[a-z][a-z0-9+.-]*:/i.test(path)) return false;
|
||||
if (path.includes("\\")) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first thing wrong with one row, or null if the normalizer would keep it.
|
||||
* Label is checked before port so the message points at the field the user
|
||||
* most likely just left blank.
|
||||
*/
|
||||
export function webEndpointRowError(
|
||||
endpoint: WebEndpoint,
|
||||
): WebEndpointFieldError | null {
|
||||
if (typeof endpoint.label !== "string" || endpoint.label.trim() === "") {
|
||||
return "label";
|
||||
}
|
||||
if (!isWebEndpointPortValid(endpoint.port)) return "port";
|
||||
if (!isWebEndpointPathValid(endpoint.path)) return "path";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first row the normalizer would drop, so a save can be blocked with a
|
||||
* message naming it. Returns null when every row would survive.
|
||||
*
|
||||
* Duplicate LABELS are reported even though the normalizer tolerates them:
|
||||
* the sidebar picker shows labels, so two identical ones are indistinguishable
|
||||
* to the user. Duplicate ids are generated, never typed.
|
||||
*/
|
||||
export function findInvalidWebEndpoint(
|
||||
endpoints: WebEndpoint[] | undefined | null,
|
||||
): { index: number; error: WebEndpointFieldError } | null {
|
||||
if (!Array.isArray(endpoints)) return null;
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (let index = 0; index < endpoints.length; index += 1) {
|
||||
const endpoint = endpoints[index];
|
||||
const error = webEndpointRowError(endpoint);
|
||||
if (error) return { index, error };
|
||||
|
||||
const label = endpoint.label.trim().toLowerCase();
|
||||
if (seen.has(label)) return { index, error: "duplicateLabel" };
|
||||
seen.add(label);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in new issue
Block a user