Files
Termix/plugins/ai/backend/egress.ts
T
LukeGus 504710b065 feat: ship the AI assistant as a first-party in-process plugin
Moves src/backend/ai and its routes into plugins/ai, mounted through a
small /ai dispatcher instead of its own port. Moves the ai-enabled and
ai-private-endpoints settings routes out of user-settings-routes.ts
into the plugin too, so it has no leftover core dependents.
2026-09-21 15:20:05 -05:00

119 lines
3.7 KiB
TypeScript

import { isIP } from "net";
import { isBlockedAddress } from "../../../src/backend/utils/safe-outbound-fetch.js";
import { createCurrentSettingsRepository } from "../../../src/backend/database/repositories/factory.js";
/**
* Where the assistant is allowed to send requests.
*
* Cloud providers are reached through the SSRF-guarded path, which refuses to
* resolve to a private address. That guard is exactly what a self-hosted Ollama
* on localhost trips over, so private destinations are permitted only when an
* admin has named the host. 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.
*/
export const AI_PRIVATE_ALLOWLIST_KEY = "ai_private_endpoint_allowlist";
/** 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 function parseAllowlist(raw: string | null): string[] {
if (!raw) return [...DEFAULT_PRIVATE_ALLOWLIST];
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [...DEFAULT_PRIVATE_ALLOWLIST];
return parsed
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean);
} catch {
return [...DEFAULT_PRIVATE_ALLOWLIST];
}
}
export async function readPrivateAllowlist(): Promise<string[]> {
const raw = await createCurrentSettingsRepository().get(
AI_PRIVATE_ALLOWLIST_KEY,
);
return parseAllowlist(raw);
}
function normalizeHost(hostname: string): string {
return hostname.replace(/^\[|\]$/g, "").toLowerCase();
}
/**
* True when the URL names a destination the SSRF guard would refuse. A bare
* hostname that is not an IP literal (e.g. "ollama.internal") is treated as
* private only if it is "localhost" -- anything else needs DNS resolution.
*/
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;
if (isIP(host)) return isBlockedAddress(host);
return false;
}
export interface EgressDecision {
allowed: boolean;
/** True when the destination needs the allowlisted-private path. */
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 isPrivate = isPrivateDestination(rawUrl);
const normalized = allowlist.map((entry) => entry.trim().toLowerCase());
// An explicitly allowlisted hostname may resolve to a private address. It
// must use the private fetch path; sending it through safeOutboundFetch
// would reject it after DNS resolution and make hostname allowlist entries
// ineffective. Only administrators can write this list.
if (normalized.includes(host)) {
return { allowed: true, isPrivate: true };
}
if (!isPrivate) return { allowed: true, isPrivate: false };
return {
allowed: false,
isPrivate: true,
reason:
"This address is on a private network. An administrator must add its host to the AI endpoint allowlist first.",
};
}