mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
135 lines
4.1 KiB
TypeScript
135 lines
4.1 KiB
TypeScript
import { BlockList, isIP } from "net";
|
|
|
|
/**
|
|
* Where the assistant is allowed to send requests.
|
|
*
|
|
* Every provider request goes through ctx.fetch, which refuses private and
|
|
* loopback addresses (after DNS, too) unless the exact host is listed. That
|
|
* guard is exactly what a self-hosted Ollama on localhost trips over, so the
|
|
* admin setting "privateEndpoints" names the hosts that may be private.
|
|
* Without that split, any logged-in user could point a "provider" at an
|
|
* internal service and use the backend as an authenticated probe of the
|
|
* server's own network.
|
|
*
|
|
* The checks here only exist to give a useful message before the request is
|
|
* made. ctx.fetch is the control.
|
|
*/
|
|
|
|
/** Hosts a self-hoster almost certainly wants, and which reach only this machine. */
|
|
export const DEFAULT_PRIVATE_ALLOWLIST = [
|
|
"localhost",
|
|
"127.0.0.1",
|
|
"::1",
|
|
"host.docker.internal",
|
|
];
|
|
|
|
export const PRIVATE_DESTINATION_MESSAGE =
|
|
"This address is on a private network. An administrator must add its host to the AI endpoint allowlist first.";
|
|
|
|
/** A bare host, not a URL: no scheme, path, port or whitespace. */
|
|
const HOST_PATTERN = /^[a-z0-9._:-]+$/;
|
|
|
|
/**
|
|
* The admin setting is one host per line. Anything that is not a bare host
|
|
* is ignored rather than failing the whole list.
|
|
*/
|
|
export function parseAllowlist(raw: unknown): string[] {
|
|
if (typeof raw !== "string") return [...DEFAULT_PRIVATE_ALLOWLIST];
|
|
const hosts: string[] = [];
|
|
for (const line of raw.split(/[\r\n,]+/)) {
|
|
const host = line.trim().toLowerCase();
|
|
if (host && HOST_PATTERN.test(host) && !hosts.includes(host)) {
|
|
hosts.push(host);
|
|
}
|
|
}
|
|
return hosts;
|
|
}
|
|
|
|
const PRIVATE_RANGES = (() => {
|
|
const list = new BlockList();
|
|
list.addSubnet("0.0.0.0", 8, "ipv4");
|
|
list.addSubnet("10.0.0.0", 8, "ipv4");
|
|
list.addSubnet("100.64.0.0", 10, "ipv4");
|
|
list.addSubnet("127.0.0.0", 8, "ipv4");
|
|
list.addSubnet("169.254.0.0", 16, "ipv4");
|
|
list.addSubnet("172.16.0.0", 12, "ipv4");
|
|
list.addSubnet("192.168.0.0", 16, "ipv4");
|
|
list.addAddress("::", "ipv6");
|
|
list.addAddress("::1", "ipv6");
|
|
list.addSubnet("fc00::", 7, "ipv6");
|
|
list.addSubnet("fe80::", 10, "ipv6");
|
|
return list;
|
|
})();
|
|
|
|
function normalizeHost(hostname: string): string {
|
|
return hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
}
|
|
|
|
/**
|
|
* True when the URL names a destination the outbound guard would refuse. A
|
|
* bare hostname that is not an IP literal (e.g. "ollama.internal") counts as
|
|
* private only if it is "localhost"; anything else needs DNS resolution,
|
|
* which ctx.fetch does.
|
|
*/
|
|
export function isPrivateDestination(rawUrl: string): boolean {
|
|
let url: URL;
|
|
try {
|
|
url = new URL(rawUrl);
|
|
} catch {
|
|
return false;
|
|
}
|
|
const host = normalizeHost(url.hostname);
|
|
if (host === "localhost" || host.endsWith(".localhost")) return true;
|
|
const family = isIP(host);
|
|
if (family === 4) return PRIVATE_RANGES.check(host, "ipv4");
|
|
if (family === 6) return PRIVATE_RANGES.check(host, "ipv6");
|
|
return false;
|
|
}
|
|
|
|
export interface EgressDecision {
|
|
allowed: boolean;
|
|
/** True when the destination is an allowlisted private host. */
|
|
isPrivate: boolean;
|
|
reason?: string;
|
|
}
|
|
|
|
export function evaluateEgress(
|
|
rawUrl: string,
|
|
allowlist: string[],
|
|
): EgressDecision {
|
|
let url: URL;
|
|
try {
|
|
url = new URL(rawUrl);
|
|
} catch {
|
|
return { allowed: false, isPrivate: false, reason: "Invalid URL" };
|
|
}
|
|
|
|
if (!["http:", "https:"].includes(url.protocol)) {
|
|
return { allowed: false, isPrivate: false, reason: "Unsupported protocol" };
|
|
}
|
|
if (url.username || url.password) {
|
|
return {
|
|
allowed: false,
|
|
isPrivate: false,
|
|
reason: "Credentials in URL are not allowed",
|
|
};
|
|
}
|
|
|
|
const host = normalizeHost(url.hostname);
|
|
const normalized = allowlist.map((entry) => entry.trim().toLowerCase());
|
|
|
|
// An allowlisted hostname may resolve to a private address; ctx.fetch
|
|
// lets it through because the same list is passed as allowPrivateHosts.
|
|
if (normalized.includes(host)) {
|
|
return { allowed: true, isPrivate: true };
|
|
}
|
|
|
|
if (!isPrivateDestination(rawUrl)) return { allowed: true, isPrivate: false };
|
|
|
|
return {
|
|
allowed: false,
|
|
isPrivate: true,
|
|
reason: PRIVATE_DESTINATION_MESSAGE,
|
|
};
|
|
}
|