feat(ai): make the local-host SSRF escape hatch a host allowlist

`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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-19 21:20:01 +02:00
1 parent 5f2a76ddbc
commit ea95826415
7 files changed
+176 -19

No files matched your search

+13 -8
View File
@@ -134,11 +134,14 @@ API key at rest like every other provider credential.
**SSRF guard.** Local endpoints route through the same validation
as every other outbound URL HealthLog hits. The guard rejects
private-IP-range targets by default — set
`ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` in the container environment to
allow `localhost`, RFC1918, and link-local destinations
(`src/lib/ai/provider.ts:456-462`). Leave it unset on public-facing
instances unless you specifically run an internal endpoint.
private-IP-range targets by default. `ALLOW_LOCAL_AI_PRIVATE_HOSTS`
opens the escape hatch and accepts two forms: a comma-separated host
allowlist (e.g. `ALLOW_LOCAL_AI_PRIVATE_HOSTS=ollama.lan,10.0.0.5`),
which permits only those exact hostnames, or the legacy
`ALLOW_LOCAL_AI_PRIVATE_HOSTS=true`, which permits any private host —
including the cloud-metadata endpoint, so prefer the host list. Leave
it unset on public-facing instances unless you specifically run an
internal endpoint.
**Model sizing.** Roughly: 7-8B for 8 GB GPU / Apple Silicon base
(briefings fine, deeper Coach reasoning gets terse), 14-24B for
@@ -232,9 +235,11 @@ error.
provider in `/settings/ai`, or configure an admin-shared OpenAI
key in the admin panel.
- **Local endpoint rejected as "internal/private host".** Set
`ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` in the container environment.
Leave it unset on internet-facing instances unless you specifically
want to allow private-range targets.
`ALLOW_LOCAL_AI_PRIVATE_HOSTS` to the endpoint's exact hostname
(e.g. `ollama.lan`; comma-separate several) in the container
environment, or to `true` to allow any private host. Prefer the host
list; leave it unset on internet-facing instances unless you
specifically want to allow private-range targets.
- **Codex disconnects after a few weeks of inactivity.** ChatGPT
refresh tokens lapse after extended idle periods. Reconnect via
the **Connect ChatGPT** button — HealthLog re-runs the device-code
+7 -4
View File
@@ -3,6 +3,7 @@ import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { isPublicUrl } from "@/lib/validations/notifications";
import { isLocalAiHostAllowed } from "@/lib/ai/local-host-allowlist";
import { encrypt, decrypt } from "@/lib/crypto";
import { resolveProviderAvailability } from "@/lib/ai/provider";
import { annotate } from "@/lib/logging/context";
@@ -92,12 +93,14 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
const trimmed = body.baseUrl.trim();
// SSRF guard: by default reject private/internal hostnames so a
// compromised user account cannot point the server at the cloud
// metadata endpoint or internal admin panels. Ops can enable local
// Ollama / LM Studio on this instance via env flag.
const allowPrivate = process.env.ALLOW_LOCAL_AI_PRIVATE_HOSTS === "true";
// metadata endpoint or internal admin panels. v1.18.7 (SECURITY LOW) —
// ops opt in via `ALLOW_LOCAL_AI_PRIVATE_HOSTS`, now a host allowlist:
// `true` permits any private host (legacy), a comma-separated host list
// permits only those exact hostnames.
const allowPrivate = isLocalAiHostAllowed(trimmed);
if (!allowPrivate && !isPublicUrl(trimmed)) {
return apiError(
"Base URL points to an internal/private host. Ops must set ALLOW_LOCAL_AI_PRIVATE_HOSTS=true on this instance to allow it (intended for self-hosted Ollama / LM Studio).",
"Base URL points to an internal/private host. Ops must allow it on this instance via ALLOW_LOCAL_AI_PRIVATE_HOSTS — set it to the exact host (e.g. ollama.lan) or to true for any private host (intended for self-hosted Ollama / LM Studio).",
422,
);
}
+10 -3
View File
@@ -10,9 +10,12 @@
*
* The LOCAL AI client is the deliberate exception: it stays CONDITIONAL
* (`requirePublicHost: !allowPrivate`) so an operator can opt into LAN hosts
* via `ALLOW_LOCAL_AI_PRIVATE_HOSTS`. We assert it keeps that conditional
* shape — never an unconditional `true` (which would break LAN local models)
* and never absent (which would re-open it for the default case).
* via `ALLOW_LOCAL_AI_PRIVATE_HOSTS`. v1.18.7 (SECURITY LOW) — that flag is
* now a host ALLOWLIST (`true` = any private host; a comma-separated list =
* only those), resolved by `isLocalAiHostAllowed`. We assert the client keeps
* the conditional shape AND derives `allowPrivate` from the allowlist helper
* (not a raw `=== "true"` binary), never an unconditional `true` (which would
* break LAN local models) and never absent (which would re-open the default).
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
@@ -59,5 +62,9 @@ describe("SSRF requirePublicHost pin inventory", () => {
// … and must NOT be hardened to an unconditional true (that would break
// a deliberately-private LAN local model).
expect(src).not.toMatch(/requirePublicHost:\s*true/);
// … and `allowPrivate` must come from the host-allowlist helper, not a
// raw binary `=== "true"` (v1.18.7 — the flag became an allowlist).
expect(src).toMatch(/allowPrivate\s*=\s*isLocalAiHostAllowed\(/);
expect(src).not.toMatch(/ALLOW_LOCAL_AI_PRIVATE_HOSTS\s*===\s*"true"/);
});
});
@@ -0,0 +1,60 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
isLocalAiHostAllowed,
requirePublicHostFor,
} from "../local-host-allowlist";
afterEach(() => {
vi.unstubAllEnvs();
});
describe("isLocalAiHostAllowed", () => {
it("denies every private host when unset (secure default)", () => {
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "");
expect(isLocalAiHostAllowed("http://10.0.0.5:11434/v1")).toBe(false);
expect(isLocalAiHostAllowed("http://ollama.lan/v1")).toBe(false);
});
it('treats "false" as denying every private host', () => {
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "false");
expect(isLocalAiHostAllowed("http://10.0.0.5/v1")).toBe(false);
});
it('keeps the legacy "true" = any private host', () => {
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
expect(isLocalAiHostAllowed("http://10.0.0.5:11434/v1")).toBe(true);
expect(isLocalAiHostAllowed("http://anything.lan/v1")).toBe(true);
});
it("permits ONLY the listed hosts when given a comma-separated allowlist", () => {
vi.stubEnv(
"ALLOW_LOCAL_AI_PRIVATE_HOSTS",
"ollama.lan, 10.0.0.5",
);
expect(isLocalAiHostAllowed("http://ollama.lan:11434/v1")).toBe(true);
expect(isLocalAiHostAllowed("http://10.0.0.5:11434/v1")).toBe(true);
// A different private host is still rejected — the metadata endpoint and
// internal panels no longer ride the binary flag.
expect(isLocalAiHostAllowed("http://169.254.169.254/latest/")).toBe(false);
expect(isLocalAiHostAllowed("http://10.0.0.6/v1")).toBe(false);
});
it("matches hostnames case-insensitively and ignores blank entries", () => {
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", " , Ollama.LAN , ");
expect(isLocalAiHostAllowed("http://ollama.lan/v1")).toBe(true);
});
it("never permits an unparseable URL", () => {
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
expect(isLocalAiHostAllowed("not a url")).toBe(true); // host-agnostic "any"
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "ollama.lan");
expect(isLocalAiHostAllowed("not a url")).toBe(false);
});
it("requirePublicHostFor is the inverse of the allowlist", () => {
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "ollama.lan");
expect(requirePublicHostFor("http://ollama.lan/v1")).toBe(false);
expect(requirePublicHostFor("http://10.0.0.6/v1")).toBe(true);
});
});
+6 -3
View File
@@ -1,4 +1,5 @@
import { safeFetch } from "@/lib/safe-fetch";
import { isLocalAiHostAllowed } from "./local-host-allowlist";
import type { AIProvider, CompletionParams, CompletionResult } from "./types";
interface LocalClientConfig {
@@ -49,9 +50,11 @@ export class LocalOpenAICompatibleClient implements AIProvider {
// pin (issue #217) extends the same flag with a connect-time
// resolved-IP check to also defeat DNS rebinding. Operators who
// legitimately point at a self-hosted Ollama / LM Studio on an
// RFC1918 address opt out via `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true`
// (also enforced at write-time in /api/user/ai-provider).
const allowPrivate = process.env.ALLOW_LOCAL_AI_PRIVATE_HOSTS === "true";
// RFC1918 address opt in via `ALLOW_LOCAL_AI_PRIVATE_HOSTS` — either the
// legacy `=true` (any private host) or a comma-separated host allowlist
// (only those hostnames). Enforced at write-time in /api/user/ai-provider
// too. v1.18.7 (SECURITY LOW) — the binary flag became a host allowlist.
const allowPrivate = isLocalAiHostAllowed(url);
const res = await safeFetch(
url,
{
+76
View File
@@ -0,0 +1,76 @@
/**
* 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);
}
+4 -1
View File
@@ -11,6 +11,7 @@ import {
decryptCodexCreds,
} from "./codex-oauth";
import { isPublicUrl } from "@/lib/validations/notifications";
import { isLocalAiHostAllowed } from "./local-host-allowlist";
import { parseProviderChain, type ProviderChainType } from "./provider-chain";
import type { ProviderChainResolved } from "./provider-runner";
@@ -668,7 +669,9 @@ export async function resolveProviderForTest(
if (!baseUrl) {
throw new AITestConfigError(422, "Local provider requires a base URL");
}
const allowPrivate = process.env.ALLOW_LOCAL_AI_PRIVATE_HOSTS === "true";
// v1.18.7 (SECURITY LOW) — host allowlist (`true` = any private host;
// a comma-separated host list = only those) replaces the binary flag.
const allowPrivate = isLocalAiHostAllowed(baseUrl);
if (!allowPrivate && !isPublicUrl(baseUrl)) {
throw new AITestConfigError(
422,