diff --git a/.env.example b/.env.example index aa5a7e3c3..6056279ba 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.env.production.example b/.env.production.example index ead149065..605ccd7e3 100644 --- a/.env.production.example +++ b/.env.production.example @@ -267,10 +267,14 @@ API_TOKEN_HMAC_KEY="" # 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 diff --git a/docker-compose.yml b/docker-compose.yml index a24b90ba0..797bd2d48 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/integrations/ai-providers.md b/docs/integrations/ai-providers.md index e4574e949..1ba9659a0 100644 --- a/docs/integrations/ai-providers.md +++ b/docs/integrations/ai-providers.md @@ -140,16 +140,28 @@ Base URL `http://: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 diff --git a/scripts/env-manifest.json b/scripts/env-manifest.json index 11fcfadb5..f0b141d6e 100644 --- a/scripts/env-manifest.json +++ b/scripts/env-manifest.json @@ -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", diff --git a/src/__tests__/compose-env-whitelist-guard.test.ts b/src/__tests__/compose-env-whitelist-guard.test.ts index 20c3ad68e..d85377847 100644 --- a/src/__tests__/compose-env-whitelist-guard.test.ts +++ b/src/__tests__/compose-env-whitelist-guard.test.ts @@ -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, diff --git a/src/app/api/labs/ocr/extract/__tests__/route.test.ts b/src/app/api/labs/ocr/extract/__tests__/route.test.ts index 66b874e13..4dcd20385 100644 --- a/src/app/api/labs/ocr/extract/__tests__/route.test.ts +++ b/src/app/api/labs/ocr/extract/__tests__/route.test.ts @@ -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", () => { diff --git a/src/app/api/labs/ocr/extract/route.ts b/src/app/api/labs/ocr/extract/route.ts index c6b1db112..16ca28416 100644 --- a/src/app/api/labs/ocr/extract/route.ts +++ b/src/app/api/labs/ocr/extract/route.ts @@ -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 } - : {}), }; } diff --git a/src/app/api/user/ai-provider/route.ts b/src/app/api/user/ai-provider/route.ts index 3aa4edf38..a10f708eb 100644 --- a/src/app/api/user/ai-provider/route.ts +++ b/src/app/api/user/ai-provider/route.ts @@ -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, ); } diff --git a/src/lib/__tests__/ssrf-pin-inventory.test.ts b/src/lib/__tests__/ssrf-pin-inventory.test.ts index 21e4886db..23a5c7934 100644 --- a/src/lib/__tests__/ssrf-pin-inventory.test.ts +++ b/src/lib/__tests__/ssrf-pin-inventory.test.ts @@ -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\(/); }); }); diff --git a/src/lib/ai/__tests__/local-client-egress.test.ts b/src/lib/ai/__tests__/local-client-egress.test.ts new file mode 100644 index 000000000..806c24874 --- /dev/null +++ b/src/lib/ai/__tests__/local-client-egress.test.ts @@ -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()), + 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", + }); + }); +}); diff --git a/src/lib/ai/__tests__/local-client.test.ts b/src/lib/ai/__tests__/local-client.test.ts index b75148df3..739540f0e 100644 --- a/src/lib/ai/__tests__/local-client.test.ts +++ b/src/lib/ai/__tests__/local-client.test.ts @@ -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(); diff --git a/src/lib/ai/__tests__/local-host-allowlist.test.ts b/src/lib/ai/__tests__/local-host-allowlist.test.ts index 0341767aa..62cf568ef 100644 --- a/src/lib/ai/__tests__/local-host-allowlist.test.ts +++ b/src/lib/ai/__tests__/local-host-allowlist.test.ts @@ -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; + +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", + }); }); }); diff --git a/src/lib/ai/__tests__/provider-contract.test.ts b/src/lib/ai/__tests__/provider-contract.test.ts index 39ed56e34..70d993be6 100644 --- a/src/lib/ai/__tests__/provider-contract.test.ts +++ b/src/lib/ai/__tests__/provider-contract.test.ts @@ -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(() => { diff --git a/src/lib/ai/local-client.ts b/src/lib/ai/local-client.ts index cd62b7dd9..6d38b8d5d 100644 --- a/src/lib/ai/local-client.ts +++ b/src/lib/ai/local-client.ts @@ -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 { 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, }, ); diff --git a/src/lib/ai/local-host-allowlist.ts b/src/lib/ai/local-host-allowlist.ts index bf26845f0..bfa13c69c 100644 --- a/src/lib/ai/local-host-allowlist.ts +++ b/src/lib/ai/local-host-allowlist.ts @@ -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 }; +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; + legacyHosts: ReadonlySet; } -/** 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(); + +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 = process.env, +): boolean { + return env[LEGACY_ENV]?.trim().toLowerCase() === "true"; +} + +function parseLegacyHosts(raw: string | undefined): ReadonlySet { + 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(); + 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(); } diff --git a/src/lib/ai/openai-client.ts b/src/lib/ai/openai-client.ts index 240e70ec9..409ba5d8c 100644 --- a/src/lib/ai/openai-client.ts +++ b/src/lib/ai/openai-client.ts @@ -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, }, ); diff --git a/src/lib/ai/provider.ts b/src/lib/ai/provider.ts index 7586504b2..ab16974bf 100644 --- a/src/lib/ai/provider.ts +++ b/src/lib/ai/provider.ts @@ -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( diff --git a/src/lib/boot/__tests__/readiness-summary.test.ts b/src/lib/boot/__tests__/readiness-summary.test.ts index 4d6211ba2..fc23b92ee 100644 --- a/src/lib/boot/__tests__/readiness-summary.test.ts +++ b/src/lib/boot/__tests__/readiness-summary.test.ts @@ -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(); + }); +}); diff --git a/src/lib/boot/readiness-summary.ts b/src/lib/boot/readiness-summary.ts index 6f1e79f15..58869d528 100644 --- a/src/lib/boot/readiness-summary.ts +++ b/src/lib/boot/readiness-summary.ts @@ -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"])],