mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
f7b3ce6ee0
commit
1a674cf4b0
20 files changed
+508
-181
No files matched your search
+8
-4
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"])],
|
||||
|
||||
Reference in new issue
Block a user