fix(security): grant private AI endpoints by exact origin, dialled pinned

ALLOW_LOCAL_AI_PRIVATE_HOSTS=true let any user of the instance point the
Local provider or the OpenAI-compatible gateway at any private address
the server could reach, the metadata range included, and a granted host
was dialled with no connect-time pin at all.

AI_PRIVATE_ORIGINS grants a private AI endpoint with the exact-origin
grammar NOTIFICATION_PRIVATE_ORIGINS and NIGHTSCOUT_PRIVATE_ORIGINS use.
A granted call goes through the pinned operator-approved dispatcher:
redirects forbidden, and metadata, link-local and unspecified answers
dropped at dial time even for a granted name. Everything else stays on
the public pin. The Local client (buffered and streaming) and the
gateway and admin-key tags of the OpenAI client all take their policy
from one function.

The host-list form of ALLOW_LOCAL_AI_PRIVATE_HOSTS keeps working with the
same pinning; a never-grantable literal or a host:port entry is refused
with a one-time warning. Its "true" form grants nothing any more and is
reported in the boot readiness summary with the replacement.

A failed lab-report scan no longer puts the endpoint's response body into
the wide event; status and model stay. The response to the caller never
carried it.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 19:46:11 +02:00
1 parent f7b3ce6ee0
commit 1a674cf4b0
20 files changed
+508 -181

No files matched your search

+8 -4
View File
@@ -109,10 +109,14 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
# Users bring their own API keys via Settings. No server-side key needed by
# default. The admin can configure a fallback key in the Admin Panel.
# Local-AI SSRF opt-in (optional). The local AI provider rejects private/
# internal hosts by default. Set to an exact host allowlist (comma-separated,
# e.g. "ollama.lan,10.0.0.5") or "true" for any private host — intended for a
# self-hosted Ollama / LM Studio endpoint. See docs/integrations/ai-providers.md.
# AI endpoints on your own network (optional). An AI base URL on a private
# address is refused unless its exact origin is listed here, comma-separated
# (e.g. "http://ollama.lan:11434,http://10.0.0.5:4000") — for a self-hosted
# Ollama, LM Studio or LiteLLM gateway. Link-local, metadata and unspecified
# addresses stay refused even when listed. See docs/integrations/ai-providers.md.
# AI_PRIVATE_ORIGINS=""
# Older form, kept for existing installs: an exact host list still works
# (e.g. "ollama.lan,10.0.0.5"). "true" no longer opens every private host.
# ALLOW_LOCAL_AI_PRIVATE_HOSTS=""
# Daily-briefing output-token ceiling (optional). Default 2500, clamped to
+8 -4
View File
@@ -267,10 +267,14 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# docs/ops/tls-cert-pin.md for extraction + the re-pin runbook.
# TLS_LEAF_SPKI_PINS=""
# Local-AI SSRF opt-in (optional). The local AI provider rejects private/
# internal hosts by default. Set to an exact host allowlist (comma-separated,
# e.g. "ollama.lan,10.0.0.5") or "true" for any private host — intended for a
# self-hosted Ollama / LM Studio endpoint. See docs/integrations/ai-providers.md.
# AI endpoints on your own network (optional). An AI base URL on a private
# address is refused unless its exact origin is listed here, comma-separated
# (e.g. "http://ollama.lan:11434,http://10.0.0.5:4000") — for a self-hosted
# Ollama, LM Studio or LiteLLM gateway. Link-local, metadata and unspecified
# addresses stay refused even when listed. See docs/integrations/ai-providers.md.
# AI_PRIVATE_ORIGINS=""
# Older form, kept for existing installs: an exact host list still works
# (e.g. "ollama.lan,10.0.0.5"). "true" no longer opens every private host.
# ALLOW_LOCAL_AI_PRIVATE_HOSTS=""
# Daily-briefing output-token ceiling (optional). Default 2500, clamped to
+10 -6
View File
@@ -253,12 +253,16 @@ x-healthlog-env: &healthlog_env
# container (the `environment:` block is a whitelist). See
# docs/ops/tls-cert-pin.md.
TLS_LEAF_SPKI_PINS: "${TLS_LEAF_SPKI_PINS:-}"
# Local-AI SSRF opt-in (optional). The local AI provider rejects
# private/internal hosts by default; set this to an exact host allowlist
# (e.g. "ollama.lan,10.0.0.5") or "true" for any private host, intended
# for a self-hosted Ollama / LM Studio. Listed here so an operator value
# in .env reaches the container (the `environment:` block is a whitelist).
# See docs/integrations/ai-providers.md.
# AI endpoints on your own network (optional): a self-hosted Ollama,
# LM Studio or LiteLLM gateway, comma-separated exact origins (e.g.
# "http://ollama.lan:11434,http://10.0.0.5:4000"). Without it an AI base
# URL on a private address is refused. Link-local, metadata and
# unspecified addresses stay refused even when listed. See
# docs/integrations/ai-providers.md.
AI_PRIVATE_ORIGINS: "${AI_PRIVATE_ORIGINS:-}"
# Older form of the above, kept for existing installs: an exact host list
# (e.g. "ollama.lan,10.0.0.5") still works. Its "true" form no longer
# opens every private host; move to AI_PRIVATE_ORIGINS.
ALLOW_LOCAL_AI_PRIVATE_HOSTS: "${ALLOW_LOCAL_AI_PRIVATE_HOSTS:-}"
# Extra hosts the admin-configured AI base URL may point at, on top of the
# built-in public providers. Comma-separated bare hostnames. Set it when
+31 -19
View File
@@ -140,16 +140,28 @@ Base URL `http://<vllm-host>:8000/v1`, API key `your-shared-secret`,
model `Qwen/Qwen2.5-14B-Instruct`. The local-client encrypts the
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. `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.
**Private endpoints.** Any user of the instance can type a base URL,
so HealthLog refuses one on a private address unless the operator has
listed it. List the endpoint's exact origin in `AI_PRIVATE_ORIGINS`,
comma-separated, the same way `NOTIFICATION_PRIVATE_ORIGINS` works for
notification relays:
```bash
AI_PRIVATE_ORIGINS="http://ollama.lan:11434,http://10.0.0.5:4000"
```
An origin is `scheme://host[:port]` and nothing else: no path, no
wildcard, no range. A listed endpoint is called through a pinned
connection that refuses to follow redirects, and the cloud-metadata
address, link-local addresses and the unspecified address stay
unreachable even when listed or when a listed name resolves to one.
`ALLOW_LOCAL_AI_PRIVATE_HOSTS`, the older setting, still works as a
host list (`ollama.lan,10.0.0.5`, any port), with the same pinning.
Its `true` form used to open every private host on the network to
every user and no longer grants anything since v1.39.3; the boot
summary names it when it is still set. Replace it with the one origin
your endpoint listens on.
**Model sizing.** Roughly: 7-8B for 8 GB GPU / Apple Silicon base
(briefings fine, deeper Coach reasoning gets terse), 14-24B for
@@ -186,9 +198,9 @@ therefore never be sent to a host you typed into a different form,
and that property is structural rather than a comment — the OPENAI
arm has no code path that reads the gateway's base URL.
**SSRF guard.** The gateway's base URL passes the same floor as the
Local provider's: a public host always, a private one only when the
operator allowlisted it via `ALLOW_LOCAL_AI_PRIVATE_HOSTS` (documented
**Private endpoints.** The gateway's base URL passes the same floor
as the Local provider's: a public host always, a private one only when
the operator listed its origin in `AI_PRIVATE_ORIGINS` (documented
under Local endpoints above). LAN gateways are the normal case for LiteLLM and vLLM, so
the escape hatch matters here as much as it does for Ollama.
@@ -439,12 +451,12 @@ upstream error.
- **"No AI provider configured."** Either set up at least one BYOK
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` 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.
- **Local endpoint rejected as "internal/private host".** Add the
endpoint's exact origin to `AI_PRIVATE_ORIGINS` in the container
environment (e.g. `http://ollama.lan:11434`; comma-separate several)
and restart. If it stopped working after an update and you had
`ALLOW_LOCAL_AI_PRIVATE_HOSTS=true`, that form no longer grants
anything; list the origin instead.
- **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
+5 -1
View File
@@ -122,9 +122,13 @@
"description": "Optional runtime policy and output tuning for user-configured AI providers. Each variable stands alone and has a safe default when unset.",
"required": false,
"variables": [
{
"name": "AI_PRIVATE_ORIGINS",
"purpose": "AI endpoints on the operator's own network (Ollama, LM Studio, LiteLLM). Comma-separated exact origins (scheme://host[:port]), e.g. \"http://ollama.lan:11434\". Unset refuses every private AI base URL. Link-local, metadata and unspecified addresses stay refused even when listed; a malformed entry is logged once and grants nothing."
},
{
"name": "ALLOW_LOCAL_AI_PRIVATE_HOSTS",
"purpose": "Local-AI SSRF opt-in. Unset rejects private/internal hosts; set an exact comma-separated host allowlist or \"true\" to allow any private host."
"purpose": "Older form of AI_PRIVATE_ORIGINS, kept for existing installs: an exact comma-separated host list still grants those hosts. \"true\" grants nothing since v1.39.3 and is reported at boot."
},
{
"name": "INSIGHTS_MAX_TOKENS",
@@ -23,10 +23,11 @@
*
* ## Why the matchers are what they are
*
* A grep for `process.env.X` alone is not the read set. Four modules resolve a
* name through a helper and index `process.env` dynamically, so their reads
* are invisible to the direct matcher — `ALLOW_LOCAL_AI_PRIVATE_HOSTS` and
* every `OIDC_*` among them. Each is registered below with its own call-shape
* A grep for `process.env.X` alone is not the read set. Three modules resolve
* a name through a helper and index `process.env` dynamically, so their reads
* are invisible to the direct matcher — every `OIDC_*` among them. (The AI
* private-host grants were a fourth until v1.39.3, when they became direct
* `process.env` reads.) Each is registered below with its own call-shape
* matcher AND its own non-zero assertion, so a refactor that renames the
* helper fails this file loudly instead of quietly shrinking the read set.
*
@@ -72,12 +73,6 @@ const INDIRECT_READERS: ReadonlyArray<{
minimum: 1,
why: "the retention windows are parsed through a local intEnv() helper",
},
{
file: "lib/ai/local-host-allowlist.ts",
pattern: /ENV_VAR\s*=\s*["']([A-Z][A-Z0-9_]*)["']/g,
minimum: 1,
why: "the name is hoisted into an ENV_VAR constant, then indexed",
},
{
file: "lib/boot/readiness-summary.ts",
pattern: /\benv\.([A-Z][A-Z0-9_]*)/g,
@@ -66,6 +66,7 @@ import { rasterizePdf } from "@/lib/documents/rasterize-pdf";
import { reserveBudget, reconcileSpend } from "@/lib/ai/coach/budget";
import { checkRateLimit, refundRateLimit } from "@/lib/rate-limit";
import { OcrExtractError, runOcrExtraction } from "@/lib/labs/ocr-extract";
import { annotate } from "@/lib/logging/context";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -164,6 +165,29 @@ describe("POST /api/labs/ocr/extract — text mode budget", () => {
expect(res.status).toBe(502);
expect(body.error).toContain("configured AI provider");
});
it("never passes the upstream body on, to the caller or to the event", async () => {
const upstreamBody = "ami-id instance-id iam/security-credentials/admin";
vi.mocked(runOcrExtraction).mockRejectedValue(
Object.assign(new Error("Local AI request failed (403)"), {
httpStatus: 403,
model: "llama3",
bodyExcerpt: upstreamBody,
}),
);
const res = await POST(textReq());
expect(res.status).toBe(502);
expect(await res.text()).not.toContain("iam/security-credentials");
const events = JSON.stringify(vi.mocked(annotate).mock.calls);
expect(events).not.toContain("iam/security-credentials");
// The status and the model stay, so an operator can still tell why.
expect(annotate).toHaveBeenCalledWith(
expect.objectContaining({
meta: expect.objectContaining({ upstreamStatus: 403, model: "llama3" }),
}),
);
});
});
describe("POST /api/labs/ocr/extract — vision PDF rasterization", () => {
+6 -4
View File
@@ -72,11 +72,16 @@ export const dynamic = "force-dynamic";
/** OCR'd text is bounded in the schema; cap the JSON body proportionally. */
const TEXT_BODY_MAX_BYTES = 512 * 1024;
/**
* The wide-event facts of a failed provider call: the status and the model,
* never the upstream body. The base URL can be one a user typed, and what an
* arbitrary endpoint answers has no business in the event stream (v1.39.3);
* the response to the caller carries neither.
*/
function providerFailureMeta(error: unknown, mode?: "text") {
const err = error as {
httpStatus?: unknown;
model?: unknown;
bodyExcerpt?: unknown;
};
return {
reason: "provider_error",
@@ -85,9 +90,6 @@ function providerFailureMeta(error: unknown, mode?: "text") {
? { upstreamStatus: err.httpStatus }
: {}),
...(typeof err.model === "string" ? { model: err.model } : {}),
...(typeof err.bodyExcerpt === "string"
? { upstreamError: err.bodyExcerpt }
: {}),
};
}
+6 -6
View File
@@ -197,14 +197,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. 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.
// metadata endpoint or internal admin panels. The operator grants a
// private endpoint by exact origin (`AI_PRIVATE_ORIGINS`, or the legacy
// host list); the retired `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` grants
// nothing since v1.39.3, and no grant can open metadata or link-local.
const allowPrivate = isLocalAiHostAllowed(trimmed);
if (!allowPrivate && !isPublicUrl(trimmed)) {
return apiError(
"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).",
"Base URL points to an internal/private host. The operator must allow its exact origin on this instance via AI_PRIVATE_ORIGINS (e.g. http://ollama.lan:11434) for a self-hosted Ollama / LM Studio.",
422,
);
}
@@ -228,7 +228,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
// here as much as it does for Ollama.
if (!isLocalAiHostAllowed(trimmed) && !isPublicUrl(trimmed)) {
return apiError(
"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. litellm.lan) or to true for any private host (intended for a self-hosted gateway).",
"Base URL points to an internal/private host. The operator must allow its exact origin on this instance via AI_PRIVATE_ORIGINS (e.g. http://litellm.lan:4000) for a self-hosted gateway.",
422,
);
}
+21 -19
View File
@@ -9,14 +9,13 @@
* instead of shipping silently.
*
* The LOCAL AI client and (since v1.37.30) the openai-client's gateway and
* admin-key tags are the deliberate exceptions: they stay CONDITIONAL
* (`requirePublicHost: !allowPrivate`) so an operator can opt into LAN hosts
* 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).
* admin-key tags are the deliberate exceptions: an operator can grant a LAN
* endpoint. Since v1.39.3 the grant is an exact origin (`AI_PRIVATE_ORIGINS`,
* or the legacy host list) and the call is dialled through the pinned
* operator-approved dispatcher, never unpinned; the whole policy comes from
* `aiEgressPolicyFor`. We assert every AI dial site spreads that policy, and
* that neither an unconditional `false` (no pin) nor a local
* `isLocalAiHostAllowed` decision (a grant with no pin) reappears there.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
@@ -35,8 +34,9 @@ describe("SSRF requirePublicHost pin inventory", () => {
it("openai-client routes person-typed base URLs through the allowlist with a pinned floor", () => {
const src = read("ai/openai-client.ts");
expect(src).toMatch(
/requirePublicHost:\s*this\.isGateway \|\| this\.type === "admin-key"\s*\? requirePublicHostFor\(this\.config\.baseUrl\)\s*:\s*true/,
/this\.isGateway \|\| this\.type === "admin-key"\s*\?\s*aiEgressPolicyFor\(url\)\s*:\s*\{\s*requirePublicHost:\s*true as const\s*\}/,
);
expect(src).not.toMatch(/requirePublicHost:\s*false/);
});
it("anthropic-client pins the BYO base-URL outbound unconditionally", () => {
@@ -83,16 +83,18 @@ describe("SSRF requirePublicHost pin inventory", () => {
},
);
it("local AI client keeps the pin CONDITIONAL (LAN escape hatch)", () => {
it("local AI client dials every call through the shared egress policy", () => {
const src = read("ai/local-client.ts");
// Must carry the conditional form …
expect(src).toMatch(/requirePublicHost:\s*!?\s*allowPrivate/);
// … 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"/);
// Both the buffered and the streaming call derive the policy …
expect([
...src.matchAll(/const egress = aiEgressPolicyFor\(url\)/g),
]).toHaveLength(2);
// … and spread it into safeFetch …
expect([...src.matchAll(/\.\.\.egress,/g)]).toHaveLength(2);
// … with no local decision beside it: no hard-coded pin value (true
// would break a granted LAN model, false would drop the pin) and no
// grant check that could skip the operator-approved dispatcher.
expect(src).not.toMatch(/requirePublicHost:\s*(?:true|false|!)/);
expect(src).not.toMatch(/isLocalAiHostAllowed\(/);
});
});
@@ -0,0 +1,86 @@
/**
* v1.39.3 — the destination policy an AI call is dialled with.
*
* A granted private origin goes to `safeFetch` as an exact
* `operatorApprovedPrivateOrigin`, which routes the connect through the pinned
* operator-approved dispatcher: redirects forbidden, metadata, link-local and
* the unspecified address dropped at dial time even for a granted name. Before
* this release a granted host was dialled with no pin at all. Everything else
* is pinned to public hosts.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { safeFetch } = vi.hoisted(() => ({ safeFetch: vi.fn() }));
vi.mock("@/lib/safe-fetch", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/safe-fetch")>()),
safeFetch,
}));
import { LocalOpenAICompatibleClient } from "../local-client";
import { _resetAiGrantsForTests } from "../local-host-allowlist";
import { singleUserTurn } from "../types";
function client(baseUrl: string) {
return new LocalOpenAICompatibleClient({
apiKey: null,
model: "llama3:8b",
baseUrl,
});
}
beforeEach(() => {
_resetAiGrantsForTests();
safeFetch.mockReset();
safeFetch.mockResolvedValue(
new Response(
JSON.stringify({ choices: [{ message: { content: "ok" } }] }),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
vi.stubEnv("AI_PRIVATE_ORIGINS", "");
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "");
vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
const call = singleUserTurn({ system: "sys", user: "hi" });
describe("Local AI egress policy", () => {
it("dials a granted origin through the operator-approved pin", async () => {
vi.stubEnv("AI_PRIVATE_ORIGINS", "http://ollama.lan:11434");
await client("http://ollama.lan:11434/v1").generateCompletion(call);
const opts = safeFetch.mock.calls[0][2];
expect(opts).toMatchObject({
requirePublicHost: false,
operatorApprovedPrivateOrigin: "http://ollama.lan:11434",
});
});
it("pins an ungranted private URL to public hosts, so safeFetch refuses it", async () => {
await client("http://10.0.0.5:11434/v1").generateCompletion(call);
const opts = safeFetch.mock.calls[0][2];
expect(opts.requirePublicHost).toBe(true);
expect(opts).not.toHaveProperty("operatorApprovedPrivateOrigin");
});
it("the retired `=true` grants nothing any more", async () => {
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
await client("http://10.0.0.5:11434/v1").generateCompletion(call);
expect(safeFetch.mock.calls[0][2].requirePublicHost).toBe(true);
});
it("streams through the same policy", async () => {
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "ollama.lan");
await client("http://ollama.lan:11434/v1")
.generateCompletionStream(call, () => {})
.catch(() => undefined);
expect(safeFetch.mock.calls[0][2]).toMatchObject({
requirePublicHost: false,
operatorApprovedPrivateOrigin: "http://ollama.lan:11434",
});
});
});
+7 -4
View File
@@ -1,4 +1,11 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// The client's destination policy has its own suite
// (`local-host-allowlist.test.ts`, `local-client-egress.test.ts`). Here the
// grant is stubbed to "no policy" so the request shape can be asserted
// against the stubbed global fetch; the pinned dispatchers drive undici's own
// fetch, which a global stub cannot see.
vi.mock("../local-host-allowlist", () => ({ aiEgressPolicyFor: () => ({}) }));
import {
LocalOpenAICompatibleClient,
resetLocalJsonDialectCache,
@@ -8,10 +15,6 @@ import { singleUserTurn } from "../types";
describe("LocalOpenAICompatibleClient", () => {
beforeEach(() => {
vi.restoreAllMocks();
// These tests cover the operator-opt-in path (LM Studio / Ollama on
// an RFC1918 host). The safeFetch wrapper enforces the SSRF guard
// unless the operator explicitly accepts a private host.
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
// v1.28.28 (#470) — the JSON dialect is cached per baseUrl for the
// process; clear it so each test starts from the default dialect.
resetLocalJsonDialectCache();
@@ -1,57 +1,116 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
_resetAiGrantsForTests,
aiEgressPolicyFor,
grantedAiOrigin,
isLocalAiHostAllowed,
requirePublicHostFor,
legacyAnyHostConfigured,
LEGACY_ANY_HOST_WARNING,
} from "../local-host-allowlist";
/**
* v1.39.3 — AI base URLs on a private network are granted by exact origin
* (`AI_PRIVATE_ORIGINS`), the grammar the notification and Nightscout grants
* use, and dialled through the pinned operator-approved dispatcher. The legacy
* host list keeps working; its `=true` form grants nothing.
*/
let warn: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
_resetAiGrantsForTests();
vi.stubEnv("AI_PRIVATE_ORIGINS", "");
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "");
warn = vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
vi.unstubAllEnvs();
warn.mockRestore();
});
describe("isLocalAiHostAllowed", () => {
it("denies every private host when unset (secure default)", () => {
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "");
describe("AI_PRIVATE_ORIGINS", () => {
it("grants nothing when unset", () => {
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("grants exactly the listed origin, not its siblings", () => {
vi.stubEnv(
"AI_PRIVATE_ORIGINS",
"http://ollama.lan:11434, http://10.0.0.5:4000",
);
expect(grantedAiOrigin("http://ollama.lan:11434/v1/chat")).toBe(
"http://ollama.lan:11434",
);
expect(isLocalAiHostAllowed("http://10.0.0.5:4000/v1")).toBe(true);
// Another port, another scheme, a subdomain: all separate origins.
expect(isLocalAiHostAllowed("http://ollama.lan:8080/v1")).toBe(false);
expect(isLocalAiHostAllowed("https://ollama.lan:11434/v1")).toBe(false);
expect(isLocalAiHostAllowed("http://evil.ollama.lan:11434/v1")).toBe(false);
expect(isLocalAiHostAllowed("http://10.0.0.6:4000/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.
it("never grants metadata, link-local or the unspecified address, even when listed", () => {
vi.stubEnv(
"AI_PRIVATE_ORIGINS",
"http://169.254.169.254,http://[fe80::1]:80,http://0.0.0.0:11434",
);
expect(isLocalAiHostAllowed("http://169.254.169.254/latest/")).toBe(false);
expect(isLocalAiHostAllowed("http://[fe80::1]/v1")).toBe(false);
expect(isLocalAiHostAllowed("http://0.0.0.0:11434/v1")).toBe(false);
expect(warn).toHaveBeenCalled();
});
});
describe("ALLOW_LOCAL_AI_PRIVATE_HOSTS (legacy)", () => {
it("still grants a listed host on any port", () => {
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:8080/v1")).toBe(true);
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("no longer opens every private host with `true`, and says so", () => {
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
expect(isLocalAiHostAllowed("http://10.0.0.5:11434/v1")).toBe(false);
expect(isLocalAiHostAllowed("http://169.254.169.254/latest/")).toBe(false);
expect(warn).toHaveBeenCalledWith(LEGACY_ANY_HOST_WARNING);
expect(legacyAnyHostConfigured()).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"
it("refuses a metadata literal and a host:port entry instead of granting a guess", () => {
vi.stubEnv(
"ALLOW_LOCAL_AI_PRIVATE_HOSTS",
"169.254.169.254,ollama.lan:11434",
);
expect(isLocalAiHostAllowed("http://169.254.169.254/latest/")).toBe(false);
expect(isLocalAiHostAllowed("http://ollama.lan:11434/v1")).toBe(false);
expect(warn).toHaveBeenCalledTimes(2);
});
it("an unparseable URL is never granted", () => {
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);
describe("aiEgressPolicyFor", () => {
it("pins an ungranted URL to the public dispatcher", () => {
expect(aiEgressPolicyFor("http://10.0.0.5/v1")).toEqual({
requirePublicHost: true,
});
expect(aiEgressPolicyFor("https://api.example.com/v1")).toEqual({
requirePublicHost: true,
});
});
it("hands a granted URL to the operator-approved dispatcher for exactly its origin", () => {
vi.stubEnv("AI_PRIVATE_ORIGINS", "http://ollama.lan:11434");
expect(aiEgressPolicyFor("http://ollama.lan:11434/v1/chat")).toEqual({
requirePublicHost: false,
operatorApprovedPrivateOrigin: "http://ollama.lan:11434",
});
});
});
@@ -53,8 +53,8 @@ describe("AIProvider contract", () => {
vi.restoreAllMocks();
// The local-provider contract test points at a localhost OpenAI-
// compatible server (Ollama / LM Studio). safeFetch enforces the
// SSRF guard unless the operator explicitly opts in.
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
// SSRF guard unless the operator grants that exact origin.
vi.stubEnv("AI_PRIVATE_ORIGINS", "http://localhost:11434");
});
afterEach(() => {
+10 -9
View File
@@ -1,6 +1,6 @@
import { safeFetch } from "@/lib/safe-fetch";
import { annotate } from "@/lib/logging/context";
import { isLocalAiHostAllowed } from "./local-host-allowlist";
import { aiEgressPolicyFor } from "./local-host-allowlist";
import type {
AIProvider,
AiMessage,
@@ -193,11 +193,12 @@ 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 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);
// RFC1918 address opt in via `AI_PRIVATE_ORIGINS` (exact origins) or the
// legacy host list. A granted origin is dialled through the pinned
// operator-approved dispatcher, never unpinned, so the metadata range and
// link-local stay unreachable even for a granted name (v1.39.3). Enforced
// at write-time in /api/user/ai-provider too.
const egress = aiEgressPolicyFor(url);
const res = await safeFetch(
url,
{
@@ -210,7 +211,7 @@ export class LocalOpenAICompatibleClient implements AIProvider {
{
// v1.22 (#89) — honour the caller's per-request timeout override; default 60 s.
timeoutMs: params.timeoutMs ?? 60_000,
requirePublicHost: !allowPrivate,
...egress,
signal: params.signal,
},
);
@@ -335,7 +336,7 @@ export class LocalOpenAICompatibleClient implements AIProvider {
onDelta: (delta: string) => void,
): Promise<CompletionResult> {
const { url, headers, body } = this.buildRequest(params, true);
const allowPrivate = isLocalAiHostAllowed(url);
const egress = aiEgressPolicyFor(url);
// Idle controller: aborts the upstream when no chunk arrives within the
// per-idle window. Composed with the caller's cancel signal so a client
@@ -355,7 +356,7 @@ export class LocalOpenAICompatibleClient implements AIProvider {
// Absolute backstop only — the per-idle timer below is the real
// ceiling. A server that streams forever still cannot pin a worker.
timeoutMs: STREAM_ABSOLUTE_CEILING_MS,
requirePublicHost: !allowPrivate,
...egress,
signal,
},
);
+144 -55
View File
@@ -1,76 +1,165 @@
/**
* v1.18.7 (SECURITY LOW) — host allowlist for local/private AI endpoints.
* Which private hosts an AI base URL may reach, and how the call is dialled.
*
* 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:
* A base URL for the Local provider, the OpenAI-compatible gateway and the
* operator's admin key is typed by a person, and for the first two that
* person is any user of the instance. By default only public hosts are
* reachable. An operator who runs Ollama, LM Studio or a LiteLLM gateway on
* their own network grants it through the environment, the same exact-origin
* grammar `NOTIFICATION_PRIVATE_ORIGINS` and `NIGHTSCOUT_PRIVATE_ORIGINS` use
* (`src/lib/private-origin-policy.ts`):
*
* - 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.
* AI_PRIVATE_ORIGINS="http://ollama.lan:11434,http://10.0.0.5:4000"
*
* 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.
* A grant is one `scheme://host[:port]`. It is never a wildcard, suffix or
* range. A granted call is dialled through the pinned operator-approved
* dispatcher with redirects forbidden, so a listed name that resolves to the
* metadata range, link-local or the unspecified address is dropped at connect
* time, and a literal in those ranges cannot be granted at all. Nothing in a
* request or a settings field can confer a grant.
*
* `ALLOW_LOCAL_AI_PRIVATE_HOSTS` (v1.18.7) stays readable for the one form that
* can be made safe: a comma-separated host list (`ollama.lan,10.0.0.5`) still
* grants those exact hosts, on any port, dialled the same pinned way. Its
* `=true` form granted every private host on the network to every user and
* cannot be made safe, so from v1.39.3 it grants nothing; the boot readiness
* summary and a one-time warning say so and name the replacement.
*/
import { isIP } from "node:net";
const ENV_VAR = "ALLOW_LOCAL_AI_PRIVATE_HOSTS";
import {
isNeverGrantableHost,
originOfUrl,
parsePrivateOrigins,
} from "@/lib/private-origin-policy";
/** Parsed allowlist policy from the env var. */
type LocalHostPolicy =
| { kind: "none" }
| { kind: "any" }
| { kind: "hosts"; hosts: ReadonlySet<string> };
const ORIGINS_ENV = "AI_PRIVATE_ORIGINS";
const LEGACY_ENV = "ALLOW_LOCAL_AI_PRIVATE_HOSTS";
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) };
/** The operator-facing sentence for a legacy `=true`. Shared with the boot summary. */
export const LEGACY_ANY_HOST_WARNING =
`${LEGACY_ENV}=true no longer opens every private host (it let any user ` +
`reach any service on the operator's network). List the AI endpoint ` +
`instead: ${ORIGINS_ENV}="http://ollama.lan:11434".`;
interface Grants {
origins: ReadonlySet<string>;
legacyHosts: ReadonlySet<string>;
}
/** 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;
let cachedKey: string | null = null;
let cached: Grants = { origins: new Set(), legacyHosts: new Set() };
const warned = new Set<string>();
function warnOnce(message: string): void {
if (warned.has(message)) return;
warned.add(message);
console.warn(message);
}
/** True when the legacy variable carries its retired `=true` form. */
export function legacyAnyHostConfigured(
env: Record<string, string | undefined> = process.env,
): boolean {
return env[LEGACY_ENV]?.trim().toLowerCase() === "true";
}
function parseLegacyHosts(raw: string | undefined): ReadonlySet<string> {
const value = raw?.trim() ?? "";
if (!value || value.toLowerCase() === "false") return new Set();
if (value.toLowerCase() === "true") {
warnOnce(LEGACY_ANY_HOST_WARNING);
return new Set();
}
const hosts = new Set<string>();
for (const entry of value.split(",")) {
const host = entry.trim().toLowerCase();
if (!host) continue;
// A host, not a URL: anything with a scheme, path, port or wildcard is a
// typo for the origin list, and granting a guess would widen it.
const bare = host.replace(/^\[|\]$/g, "");
const isIpv6 = isIP(bare) === 6;
if (!/^[a-z0-9.\-[\]:]+$/.test(host) || (host.includes(":") && !isIpv6)) {
warnOnce(
`${LEGACY_ENV}: ignoring entry "${host.slice(0, 200)}": a host name or address, nothing else; use ${ORIGINS_ENV} for a full origin`,
);
continue;
}
if (isNeverGrantableHost(host)) {
warnOnce(
`${LEGACY_ENV}: ignoring entry "${host}": the unspecified address, link-local and metadata addresses cannot be granted`,
);
continue;
}
hosts.add(bare);
}
return hosts;
}
function grants(): Grants {
const originsRaw = process.env.AI_PRIVATE_ORIGINS;
const legacyRaw = process.env.ALLOW_LOCAL_AI_PRIVATE_HOSTS;
const key = `${originsRaw ?? ""}\u0000${legacyRaw ?? ""}`;
if (key === cachedKey) return cached;
cached = {
origins: parsePrivateOrigins(originsRaw, (entry, reason) => {
warnOnce(`${ORIGINS_ENV}: ignoring entry "${entry}": ${reason}`);
}),
legacyHosts: parseLegacyHosts(legacyRaw),
};
cachedKey = key;
return cached;
}
/**
* 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.
* The granted origin for this URL, or null. A URL is granted when its exact
* origin is listed in `AI_PRIVATE_ORIGINS`, or its host is listed in the
* legacy host list. A host no grant may name is never granted, whatever the
* lists say.
*/
export function grantedAiOrigin(url: string): string | null {
// Parse the grants first, so a malformed entry is reported on the first
// call that could have used it rather than never.
const { origins, legacyHosts } = grants();
const origin = originOfUrl(url);
if (!origin) return null;
const hostname = new URL(origin).hostname.toLowerCase();
// Both lists refuse a never-grantable literal when they are parsed, so
// neither can hold one; a listed name that resolves into those ranges is
// dropped by the pinned dispatcher at dial time.
if (origins.has(origin)) return origin;
if (legacyHosts.has(hostname.replace(/^\[|\]$/g, ""))) return origin;
return null;
}
/**
* True when the operator granted this URL's origin. Answers "may this private
* host be saved and called?", never "is it public?": callers still accept a
* public URL without any grant.
*/
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);
return grantedAiOrigin(url) !== null;
}
/**
* 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.
* The `safeFetch` destination policy for an AI call to `url`: the pinned
* public dispatcher unless the operator granted the origin, and then the
* pinned operator-approved dispatcher for that one origin. Spread into the
* `safeFetch` options.
*/
export function requirePublicHostFor(url: string): boolean {
return !isLocalAiHostAllowed(url);
export function aiEgressPolicyFor(
url: string,
):
| { requirePublicHost: true }
| { requirePublicHost: false; operatorApprovedPrivateOrigin: string } {
const origin = grantedAiOrigin(url);
return origin === null
? { requirePublicHost: true }
: { requirePublicHost: false, operatorApprovedPrivateOrigin: origin };
}
/** Test helper: forget the parsed grants and the warnings already printed. */
export function _resetAiGrantsForTests(): void {
cachedKey = null;
warned.clear();
}
+6 -6
View File
@@ -8,7 +8,7 @@ import {
jsonModeDialectFor,
rememberJsonModeDialect,
} from "./json-dialect";
import { requirePublicHostFor } from "./local-host-allowlist";
import { aiEgressPolicyFor } from "./local-host-allowlist";
import {
buildOpenAIMessages,
buildOpenAITools,
@@ -152,7 +152,8 @@ export class OpenAIClient implements AIProvider {
// v1.21.5 — honour the caller's per-request timeout override; default 60 s.
// v1.33.1 (#470) — the gateway tag reuses the Local provider's host
// policy verbatim: public hosts always; a private host only when the
// operator allowlisted it via ALLOW_LOCAL_AI_PRIVATE_HOSTS.
// operator granted its origin (`AI_PRIVATE_ORIGINS` or the legacy host
// list), and then only through the pinned operator-approved dispatcher.
//
// v1.37.30 — the `admin-key` tag consults the same policy. Its base URL
// is the one other operator-typed URL in this client (an operator can
@@ -167,10 +168,9 @@ export class OpenAIClient implements AIProvider {
// operator legitimately redirects.
{
timeoutMs: params.timeoutMs ?? 60_000,
requirePublicHost:
this.isGateway || this.type === "admin-key"
? requirePublicHostFor(this.config.baseUrl)
: true,
...(this.isGateway || this.type === "admin-key"
? aiEgressPolicyFor(url)
: { requirePublicHost: true as const }),
signal: params.signal,
},
);
+2 -2
View File
@@ -1189,8 +1189,8 @@ export async function resolveProviderForTest(
if (!baseUrl) {
throw new AITestConfigError(422, "Local provider requires a base URL");
}
// v1.18.7 (SECURITY LOW) — host allowlist (`true` = any private host;
// a comma-separated host list = only those) replaces the binary flag.
// A private host only when the operator granted its origin
// (`AI_PRIVATE_ORIGINS` or the legacy host list; v1.39.3).
const allowPrivate = isLocalAiHostAllowed(baseUrl);
if (!allowPrivate && !isPublicUrl(baseUrl)) {
throw new AITestConfigError(
@@ -129,3 +129,23 @@ describe("formatReadiness + logReadinessSummary", () => {
expect(errors).toHaveLength(1);
});
});
describe("collectReadiness — retired AI grant", () => {
it("warns, without blocking, when ALLOW_LOCAL_AI_PRIVATE_HOSTS is still `true`", () => {
const env = { ...baseEnv(), ALLOW_LOCAL_AI_PRIVATE_HOSTS: " TRUE " };
const line = lineFor(env, "ALLOW_LOCAL_AI_PRIVATE_HOSTS");
expect(line?.status).toBe("warn");
expect(line?.detail).toContain("AI_PRIVATE_ORIGINS");
expect(collectReadiness(env).hasBlocker).toBe(false);
});
it("says nothing for a host list or an unset variable", () => {
expect(
lineFor(
{ ...baseEnv(), ALLOW_LOCAL_AI_PRIVATE_HOSTS: "ollama.lan" },
"ALLOW_LOCAL_AI_PRIVATE_HOSTS",
),
).toBeUndefined();
expect(lineFor(baseEnv(), "ALLOW_LOCAL_AI_PRIVATE_HOSTS")).toBeUndefined();
});
});
+18
View File
@@ -15,6 +15,11 @@
* signpost in front of them.
*/
import {
LEGACY_ANY_HOST_WARNING,
legacyAnyHostConfigured,
} from "@/lib/ai/local-host-allowlist";
export interface ReadinessLine {
/** Short label, e.g. "ENCRYPTION_KEY". */
label: string;
@@ -121,6 +126,19 @@ export function collectReadiness(env: Env = process.env): ReadinessReport {
: "Secure cookie flag OFF — fine for plain-HTTP LAN; do NOT expose plain HTTP to the internet",
});
// --- Retired AI grant (a warning, never a blocker) ----------------------
// `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` used to open every private host on
// the network to every user's AI base URL. It grants nothing since
// v1.39.3; an operator who still has it set has a local AI endpoint that
// just stopped answering, and this is the line that says why.
if (legacyAnyHostConfigured(env)) {
lines.push({
label: "ALLOW_LOCAL_AI_PRIVATE_HOSTS",
status: "warn",
detail: LEGACY_ANY_HOST_WARNING,
});
}
// --- Optional subsystems (informational) --------------------------------
const optional: Array<[string, boolean]> = [
["SMTP (email)", allSet(env, ["SMTP_HOST", "SMTP_PORT", "SMTP_FROM"])],