mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
`ALLOW_LOCAL_AI_PRIVATE_HOSTS` was a binary flag that, when `true`, opened every private/internal host at once — including the cloud metadata endpoint and any internal panel. Replace the binary check at all three sites (write-time validation, local client, test resolver) with a shared `isLocalAiHostAllowed` helper: a comma-separated host list permits only those exact hostnames, while the legacy `true` still permits any private host for backward compatibility. The safeFetch / isPublicUrl floor is untouched; the allowlist only widens what the operator deliberately permits. Docs and the SSRF-pin inventory test are updated accordingly.
77 lines
3.0 KiB
TypeScript
77 lines
3.0 KiB
TypeScript
/**
|
|
* v1.18.7 (SECURITY LOW) — host allowlist for local/private AI endpoints.
|
|
*
|
|
* The operator escape-hatch for pointing the server at a self-hosted
|
|
* Ollama / LM Studio on an RFC1918 address used to be a single binary flag,
|
|
* `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true`, which opened EVERY private/internal
|
|
* host at once — including the cloud-metadata endpoint and any internal admin
|
|
* panel. This narrows it to an explicit allowlist while keeping the binary
|
|
* `true` for backward compatibility:
|
|
*
|
|
* - unset / "" / "false" → no private host allowed (the secure default).
|
|
* - "true" → ANY private host allowed (legacy behaviour).
|
|
* - "ollama.lan, 10.0.0.5" → ONLY those exact hostnames allowed; every
|
|
* other private host is still rejected.
|
|
*
|
|
* This is layered ON TOP of the `safeFetch` / `isPublicUrl` floor: a public
|
|
* URL never needs the allowlist, and a private host not on the list is
|
|
* rejected exactly as before. The allowlist only ever WIDENS what the
|
|
* operator deliberately permits, never narrows the public-host floor.
|
|
*/
|
|
|
|
const ENV_VAR = "ALLOW_LOCAL_AI_PRIVATE_HOSTS";
|
|
|
|
/** Parsed allowlist policy from the env var. */
|
|
type LocalHostPolicy =
|
|
| { kind: "none" }
|
|
| { kind: "any" }
|
|
| { kind: "hosts"; hosts: ReadonlySet<string> };
|
|
|
|
function parsePolicy(): LocalHostPolicy {
|
|
const raw = process.env[ENV_VAR]?.trim();
|
|
if (!raw || raw.toLowerCase() === "false") return { kind: "none" };
|
|
if (raw.toLowerCase() === "true") return { kind: "any" };
|
|
const hosts = raw
|
|
.split(",")
|
|
.map((h) => h.trim().toLowerCase())
|
|
.filter((h) => h.length > 0);
|
|
if (hosts.length === 0) return { kind: "none" };
|
|
return { kind: "hosts", hosts: new Set(hosts) };
|
|
}
|
|
|
|
/** Extract the lowercased hostname from a URL string, or null if unparseable. */
|
|
function hostnameOf(url: string): string | null {
|
|
try {
|
|
return new URL(url).hostname.toLowerCase();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* True when the operator has explicitly permitted this (private) host for a
|
|
* local AI endpoint. `"true"` permits any host (legacy); a host list permits
|
|
* only listed hostnames. An unparseable URL is never permitted.
|
|
*
|
|
* NOTE: this answers "did the operator opt this host in?", NOT "is this host
|
|
* public?". Callers still run the `isPublicUrl` / `safeFetch` floor; this is
|
|
* the private-host override on top of it.
|
|
*/
|
|
export function isLocalAiHostAllowed(url: string): boolean {
|
|
const policy = parsePolicy();
|
|
if (policy.kind === "none") return false;
|
|
if (policy.kind === "any") return true;
|
|
const host = hostnameOf(url);
|
|
return host !== null && policy.hosts.has(host);
|
|
}
|
|
|
|
/**
|
|
* Whether `safeFetch` must require a public host for this URL: true unless the
|
|
* operator has opted this private host into the allowlist. Kept as a thin
|
|
* wrapper so the call sites read `requirePublicHost: requirePublicHostFor(url)`
|
|
* and the conditional intent stays obvious.
|
|
*/
|
|
export function requirePublicHostFor(url: string): boolean {
|
|
return !isLocalAiHostAllowed(url);
|
|
}
|