mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
5f2a76ddbc
commit
ea95826415
7 files changed
+176
-19
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user