mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(security): keep ALLOW_LOCAL_AI_PRIVATE_HOSTS=true for the operator's own AI, deprecated
Making `=true` grant nothing would stop a single-user self-host's local model on a patch update. It is deprecated for one release cycle instead, in a narrowed and safe form: it still grants a private host, but only to the operator's own configurations (the instance-wide admin provider and AI settings saved on an admin account), and only through the pinned operator-approved dispatcher, so redirects stay forbidden and metadata, link-local and unspecified addresses stay refused. A non-admin account's own base URL needs an exact origin in AI_PRIVATE_ORIGINS. Provider resolution passes the owning account's role to the client (`operatorTrusted`); the save route and the connection test apply the same rule to the account being configured. The boot summary now says the setting is deprecated and names the replacement, and the admin AI settings page shows the exact AI_PRIVATE_ORIGINS line for the private origins saved on the instance (every account's Local and gateway URL and the admin base URL, scheme, host and port only), so the operator can copy it and drop the old setting.
This commit is contained in:
1 parent
76398c80c2
commit
c86f23c2c0
28 files changed
+863
-55
No files matched your search
@@ -6,7 +6,7 @@
|
||||
* HTTPS + hostname allowlist on the base URL, and the masked
|
||||
* `keyPreview` in every response.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
@@ -15,6 +15,7 @@ vi.mock("@/lib/db", () => ({
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
user: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -100,6 +101,7 @@ describe("GET /api/admin/ai-settings", () => {
|
||||
keyPreview: null,
|
||||
model: "gpt-4o",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
legacyPrivateHostGrant: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -216,3 +218,52 @@ describe("PUT /api/admin/ai-settings", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.39.3 — while the deprecated `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` is set,
|
||||
* the page names it and lists the origins it is standing in for, so the
|
||||
* operator can copy them into `AI_PRIVATE_ORIGINS`.
|
||||
*/
|
||||
describe("GET /api/admin/ai-settings — deprecated private-host grant", () => {
|
||||
beforeEach(async () => {
|
||||
const { _resetAiGrantsForTests } =
|
||||
await import("@/lib/ai/local-host-allowlist");
|
||||
_resetAiGrantsForTests();
|
||||
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
adminAiKeyEncrypted: null,
|
||||
adminAiModel: null,
|
||||
adminAiBaseUrl: "http://gateway.lan:4000/v1",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findMany).mockResolvedValue([
|
||||
{ aiBaseUrl: "http://10.0.0.5:11434/v1", aiCompatBaseUrl: null },
|
||||
{
|
||||
aiBaseUrl: "http://10.0.0.5:11434/v1/",
|
||||
aiCompatBaseUrl: "https://openrouter.ai/api/v1",
|
||||
},
|
||||
{ aiBaseUrl: "http://169.254.169.254/latest", aiCompatBaseUrl: null },
|
||||
] as never);
|
||||
vi.stubEnv("AI_PRIVATE_ORIGINS", "");
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("lists the private origins in use, once each, when `true` is set", async () => {
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
|
||||
const res = await GET();
|
||||
const body = await res.json();
|
||||
expect(body.data.legacyPrivateHostGrant).toEqual({
|
||||
originsInUse: ["http://10.0.0.5:11434", "http://gateway.lan:4000"],
|
||||
});
|
||||
});
|
||||
|
||||
it("is null when `true` is not set", async () => {
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "ollama.lan");
|
||||
const res = await GET();
|
||||
expect((await res.json()).data.legacyPrivateHostGrant).toBeNull();
|
||||
expect(prisma.user.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { apiSuccess, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import { encrypt, decrypt } from "@/lib/crypto";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { legacyPrivateHostReport } from "@/lib/ai/legacy-private-host-report";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -28,6 +29,11 @@ export const GET = apiHandler(async () => {
|
||||
: null,
|
||||
model,
|
||||
baseUrl,
|
||||
// v1.39.3 — present while the deprecated ALLOW_LOCAL_AI_PRIVATE_HOSTS=true
|
||||
// is set, naming the origins to move into AI_PRIVATE_ORIGINS.
|
||||
legacyPrivateHostGrant: await legacyPrivateHostReport(
|
||||
settings?.adminAiBaseUrl,
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* v1.39.3 — `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` is deprecated and narrowed,
|
||||
* not removed. Saving a private base URL under it is still accepted for the
|
||||
* operator's own configuration (an admin account) and refused for any other
|
||||
* account, which needs an exact origin in `AI_PRIVATE_ORIGINS`. Metadata and
|
||||
* link-local are refused for everyone. The real policy and the real public-host
|
||||
* check run here; only persistence is stubbed.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({
|
||||
user: { id: "u-1" },
|
||||
session: { id: "s-1" },
|
||||
})),
|
||||
HttpError: class HttpError extends Error {
|
||||
status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { findUnique: vi.fn(), update: vi.fn() } },
|
||||
}));
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((v: string) => `dec:${v}`),
|
||||
encrypt: vi.fn((v: string) => `enc:${v}`),
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider", () => ({
|
||||
resolveProviderAvailability: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/ai/server-provider-health", () => ({
|
||||
readServerProviderHealth: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/feature-flags", async () =>
|
||||
(
|
||||
await import("@/__tests__/helpers/assistant-switches-mock")
|
||||
).mockAssistantSwitches(vi.fn()),
|
||||
);
|
||||
vi.mock("@/lib/sharing/provider-work-authority", () => ({
|
||||
providerWorkAuthorityForRecord: vi.fn(() => ({ origin: "owner" })),
|
||||
providerCredentialPolicy: vi.fn(() => "personal"),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
hasActiveConsentForSurface: vi.fn(),
|
||||
}));
|
||||
|
||||
import { PATCH } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { _resetAiGrantsForTests } from "@/lib/ai/local-host-allowlist";
|
||||
|
||||
const patch = PATCH as (req: Request) => Promise<Response>;
|
||||
|
||||
function save(body: Record<string, string>) {
|
||||
return patch(
|
||||
new Request("http://localhost/api/user/ai-provider", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function asRole(role: "ADMIN" | "USER") {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({ role } as never);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(prisma.user.findUnique).mockReset();
|
||||
vi.mocked(prisma.user.update).mockReset();
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
_resetAiGrantsForTests();
|
||||
vi.stubEnv("AI_PRIVATE_ORIGINS", "");
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("PATCH /api/user/ai-provider under ALLOW_LOCAL_AI_PRIVATE_HOSTS=true", () => {
|
||||
it.each([
|
||||
["baseUrl", "http://10.0.0.5:11434/v1"],
|
||||
["compatBaseUrl", "http://192.168.1.20:4000/v1"],
|
||||
])("refuses a non-admin account's private %s", async (field, url) => {
|
||||
asRole("USER");
|
||||
const res = await save({ [field]: url });
|
||||
expect(res.status).toBe(422);
|
||||
expect((await res.json()).error).toContain("AI_PRIVATE_ORIGINS");
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["baseUrl", "http://10.0.0.5:11434/v1"],
|
||||
["compatBaseUrl", "http://192.168.1.20:4000/v1"],
|
||||
])("accepts an admin account's private %s", async (field, url) => {
|
||||
asRole("ADMIN");
|
||||
const res = await save({ [field]: url });
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"http://169.254.169.254/latest/meta-data/",
|
||||
"http://[fe80::1]:11434/v1",
|
||||
"http://0.0.0.0:11434/v1",
|
||||
])("refuses %s even for an admin account", async (url) => {
|
||||
asRole("ADMIN");
|
||||
const res = await save({ baseUrl: url });
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts a non-admin account's private URL once its exact origin is granted", async () => {
|
||||
vi.stubEnv("AI_PRIVATE_ORIGINS", "http://10.0.0.5:11434");
|
||||
asRole("USER");
|
||||
const res = await save({ baseUrl: "http://10.0.0.5:11434/v1" });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -179,6 +179,26 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
const body = parsed.data;
|
||||
|
||||
// v1.39.3 — whose configuration this is. Settings saved on an admin account
|
||||
// are the operator's own and are still covered by the deprecated
|
||||
// `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true`; any other account needs an exact
|
||||
// origin in `AI_PRIVATE_ORIGINS`. Read from the saved row, the same rule
|
||||
// the provider resolution applies when it later dials the URL.
|
||||
const needsHostPolicy =
|
||||
(typeof body.baseUrl === "string" && body.baseUrl !== "") ||
|
||||
(typeof body.compatBaseUrl === "string" && body.compatBaseUrl !== "");
|
||||
const owner = needsHostPolicy
|
||||
? {
|
||||
operatorTrusted:
|
||||
(
|
||||
await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { role: true },
|
||||
})
|
||||
)?.role === "ADMIN",
|
||||
}
|
||||
: {};
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
|
||||
if (body.provider !== undefined) {
|
||||
@@ -199,9 +219,10 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
// compromised user account cannot point the server at the cloud
|
||||
// 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);
|
||||
// host list); the deprecated `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` covers
|
||||
// an admin account's own settings only (`owner` above), and no grant
|
||||
// can open metadata or link-local.
|
||||
const allowPrivate = isLocalAiHostAllowed(trimmed, owner);
|
||||
if (!allowPrivate && !isPublicUrl(trimmed)) {
|
||||
return apiError(
|
||||
"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.",
|
||||
@@ -226,7 +247,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
// private one only when the operator allowlisted it. Gateways on a LAN
|
||||
// are the normal case for LiteLLM / vLLM, so the escape hatch matters
|
||||
// here as much as it does for Ollama.
|
||||
if (!isLocalAiHostAllowed(trimmed) && !isPublicUrl(trimmed)) {
|
||||
if (!isLocalAiHostAllowed(trimmed, owner) && !isPublicUrl(trimmed)) {
|
||||
return apiError(
|
||||
"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,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.39.3 — the admin AI card names the deprecated
|
||||
* `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` and gives the exact
|
||||
* `AI_PRIVATE_ORIGINS` line to set instead, built from the origins the server
|
||||
* reports in use. Absent when the server reports no such grant.
|
||||
*/
|
||||
|
||||
const state = vi.hoisted(() => ({ data: null as null | object }));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: state.data }),
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { AiServerKeySection } from "../ai-server-key-section";
|
||||
|
||||
function render(): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<AiServerKeySection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const base = {
|
||||
hasKey: false,
|
||||
keyPreview: null,
|
||||
model: "gpt-4o",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
};
|
||||
|
||||
describe("<AiServerKeySection> deprecated private-host notice", () => {
|
||||
it("shows the exact line to set, with every origin in use", () => {
|
||||
state.data = {
|
||||
...base,
|
||||
legacyPrivateHostGrant: {
|
||||
originsInUse: ["http://10.0.0.5:11434", "http://gateway.lan:4000"],
|
||||
},
|
||||
};
|
||||
const html = render();
|
||||
expect(html).toContain('data-slot="admin-ai-legacy-private-hosts"');
|
||||
expect(html).toContain("is deprecated");
|
||||
expect(html).toContain(
|
||||
"AI_PRIVATE_ORIGINS="http://10.0.0.5:11434,http://gateway.lan:4000"",
|
||||
);
|
||||
});
|
||||
|
||||
it("says the setting can go when no saved URL needs it", () => {
|
||||
state.data = { ...base, legacyPrivateHostGrant: { originsInUse: [] } };
|
||||
const html = render();
|
||||
expect(html).toContain("you can remove ALLOW_LOCAL_AI_PRIVATE_HOSTS now");
|
||||
expect(html).not.toContain("admin-ai-legacy-private-hosts-line");
|
||||
});
|
||||
|
||||
it("is absent when the server reports no deprecated grant", () => {
|
||||
state.data = { ...base, legacyPrivateHostGrant: null };
|
||||
expect(render()).not.toContain("admin-ai-legacy-private-hosts");
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { KeyRound, Loader2 } from "lucide-react";
|
||||
import { AlertTriangle, KeyRound, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -49,6 +49,12 @@ interface AiServerKeyResponse {
|
||||
keyPreview: string | null;
|
||||
model: string;
|
||||
baseUrl: string;
|
||||
/**
|
||||
* v1.39.3 — present while the deprecated
|
||||
* `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` is set: the origins it is standing in
|
||||
* for, to copy into `AI_PRIVATE_ORIGINS`.
|
||||
*/
|
||||
legacyPrivateHostGrant?: { originsInUse: string[] } | null;
|
||||
}
|
||||
|
||||
export function AiServerKeySection() {
|
||||
@@ -120,6 +126,11 @@ export function AiServerKeySection() {
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
{settings?.legacyPrivateHostGrant ? (
|
||||
<LegacyPrivateHostNotice
|
||||
originsInUse={settings.legacyPrivateHostGrant.originsInUse}
|
||||
/>
|
||||
) : null}
|
||||
<div className="space-y-2 text-sm">
|
||||
<p>{t("admin.aiServerKey.byokHint")}</p>
|
||||
<p>{t("admin.aiServerKey.consentHint")}</p>
|
||||
@@ -201,3 +212,43 @@ export function AiServerKeySection() {
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.39.3 — the deprecated `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true`, with the exact
|
||||
* line to set instead. The origins come from the saved base URLs on this
|
||||
* instance; the operator copies the line into the server environment.
|
||||
*/
|
||||
function LegacyPrivateHostNotice({ originsInUse }: { originsInUse: string[] }) {
|
||||
const { t } = useTranslations();
|
||||
const line = `AI_PRIVATE_ORIGINS="${originsInUse.join(",")}"`;
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
data-slot="admin-ai-legacy-private-hosts"
|
||||
className="border-warning/40 bg-warning/10 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="font-medium">
|
||||
{t("admin.aiServerKey.legacyGrantTitle")}
|
||||
</p>
|
||||
<p>{t("admin.aiServerKey.legacyGrantBody")}</p>
|
||||
{originsInUse.length > 0 ? (
|
||||
<>
|
||||
<p>{t("admin.aiServerKey.legacyGrantOrigins")}</p>
|
||||
<code
|
||||
data-slot="admin-ai-legacy-private-hosts-line"
|
||||
className="bg-muted block overflow-x-auto rounded px-2 py-1 font-mono text-xs break-all select-all"
|
||||
>
|
||||
{line}
|
||||
</code>
|
||||
</>
|
||||
) : (
|
||||
<p>{t("admin.aiServerKey.legacyGrantNoOrigins")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,7 +34,7 @@ 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(
|
||||
/this\.isGateway \|\| this\.type === "admin-key"\s*\?\s*aiEgressPolicyFor\(url\)\s*:\s*\{\s*requirePublicHost:\s*true as const\s*\}/,
|
||||
/this\.isGateway \|\| this\.type === "admin-key"\s*\?\s*aiEgressPolicyFor\(url,\s*\{\s*operatorTrusted:\s*this\.config\.operatorTrusted,?\s*\}\)\s*:\s*\{\s*requirePublicHost:\s*true as const\s*\}/,
|
||||
);
|
||||
expect(src).not.toMatch(/requirePublicHost:\s*false/);
|
||||
});
|
||||
@@ -87,7 +87,9 @@ describe("SSRF requirePublicHost pin inventory", () => {
|
||||
const src = read("ai/local-client.ts");
|
||||
// Both the buffered and the streaming call derive the policy …
|
||||
expect([
|
||||
...src.matchAll(/const egress = aiEgressPolicyFor\(url\)/g),
|
||||
...src.matchAll(
|
||||
/const egress = aiEgressPolicyFor\(url,\s*\{\s*operatorTrusted:\s*this\.config\.operatorTrusted,?\s*\}\)/g,
|
||||
),
|
||||
]).toHaveLength(2);
|
||||
// … and spread it into safeFetch …
|
||||
expect([...src.matchAll(/\.\.\.egress,/g)]).toHaveLength(2);
|
||||
|
||||
@@ -67,12 +67,26 @@ describe("Local AI egress policy", () => {
|
||||
expect(opts).not.toHaveProperty("operatorApprovedPrivateOrigin");
|
||||
});
|
||||
|
||||
it("the retired `=true` grants nothing any more", async () => {
|
||||
it("the deprecated `=true` no longer covers a non-admin account's endpoint", 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("the deprecated `=true` still covers the operator's own endpoint, pinned", async () => {
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
|
||||
await new LocalOpenAICompatibleClient({
|
||||
apiKey: null,
|
||||
model: "llama3:8b",
|
||||
baseUrl: "http://10.0.0.5:11434/v1",
|
||||
operatorTrusted: true,
|
||||
}).generateCompletion(call);
|
||||
expect(safeFetch.mock.calls[0][2]).toMatchObject({
|
||||
requirePublicHost: false,
|
||||
operatorApprovedPrivateOrigin: "http://10.0.0.5:11434",
|
||||
});
|
||||
});
|
||||
|
||||
it("streams through the same policy", async () => {
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "ollama.lan");
|
||||
await client("http://ollama.lan:11434/v1")
|
||||
|
||||
@@ -7,13 +7,15 @@ import {
|
||||
isLocalAiHostAllowed,
|
||||
legacyAnyHostConfigured,
|
||||
LEGACY_ANY_HOST_WARNING,
|
||||
originNeedingAiGrant,
|
||||
} 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.
|
||||
* host list keeps working; its deprecated `=true` form covers only the
|
||||
* operator's own configurations.
|
||||
*/
|
||||
|
||||
let warn: ReturnType<typeof vi.spyOn>;
|
||||
@@ -72,12 +74,50 @@ describe("ALLOW_LOCAL_AI_PRIVATE_HOSTS (legacy)", () => {
|
||||
expect(isLocalAiHostAllowed("http://10.0.0.6/v1")).toBe(false);
|
||||
});
|
||||
|
||||
it("no longer opens every private host with `true`, and says so", () => {
|
||||
it("`true` still covers an operator-owned configuration, pinned, and says it is deprecated", () => {
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
|
||||
expect(
|
||||
aiEgressPolicyFor("http://10.0.0.5:11434/v1", { operatorTrusted: true }),
|
||||
).toEqual({
|
||||
requirePublicHost: false,
|
||||
operatorApprovedPrivateOrigin: "http://10.0.0.5:11434",
|
||||
});
|
||||
expect(warn).toHaveBeenCalledWith(LEGACY_ANY_HOST_WARNING);
|
||||
expect(LEGACY_ANY_HOST_WARNING).toContain("deprecated");
|
||||
expect(LEGACY_ANY_HOST_WARNING).toContain("AI_PRIVATE_ORIGINS=");
|
||||
expect(legacyAnyHostConfigured()).toBe(true);
|
||||
});
|
||||
|
||||
it("`true` no longer covers a non-admin account's own base URL", () => {
|
||||
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);
|
||||
expect(
|
||||
isLocalAiHostAllowed("http://10.0.0.5:11434/v1", {
|
||||
operatorTrusted: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(aiEgressPolicyFor("http://10.0.0.5:11434/v1")).toEqual({
|
||||
requirePublicHost: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("`true` never opens metadata, link-local or the unspecified address, even for the operator", () => {
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
|
||||
for (const url of [
|
||||
"http://169.254.169.254/latest/meta-data/",
|
||||
"http://[fe80::1]/v1",
|
||||
"http://0.0.0.0:11434/v1",
|
||||
"http://[::ffff:169.254.169.254]/v1",
|
||||
]) {
|
||||
expect(isLocalAiHostAllowed(url, { operatorTrusted: true }), url).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("an exact origin grant covers every account, with or without `true`", () => {
|
||||
vi.stubEnv("AI_PRIVATE_ORIGINS", "http://ollama.lan:11434");
|
||||
expect(isLocalAiHostAllowed("http://ollama.lan:11434/v1")).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a metadata literal and a host:port entry instead of granting a guess", () => {
|
||||
@@ -114,3 +154,30 @@ describe("aiEgressPolicyFor", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("originNeedingAiGrant", () => {
|
||||
it("names the origin of a LAN endpoint no grant covers yet", () => {
|
||||
for (const [url, origin] of [
|
||||
["http://10.0.0.5:11434/v1", "http://10.0.0.5:11434"],
|
||||
["http://ollama.lan:11434/v1", "http://ollama.lan:11434"],
|
||||
["http://ollama:11434/v1", "http://ollama:11434"],
|
||||
["http://nas.local:1234/v1", "http://nas.local:1234"],
|
||||
["http://localhost:11434/v1", "http://localhost:11434"],
|
||||
]) {
|
||||
expect(originNeedingAiGrant(url), url).toBe(origin);
|
||||
}
|
||||
});
|
||||
|
||||
it("skips hosted endpoints, never-grantable addresses and origins already granted", () => {
|
||||
vi.stubEnv("AI_PRIVATE_ORIGINS", "http://10.0.0.5:11434");
|
||||
for (const url of [
|
||||
"https://openrouter.ai/api/v1",
|
||||
"https://api.openai.com/v1",
|
||||
"http://169.254.169.254/latest",
|
||||
"http://10.0.0.5:11434/v1",
|
||||
"not a url",
|
||||
]) {
|
||||
expect(originNeedingAiGrant(url), url).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* v1.39.3 — the deprecated `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` at dial time.
|
||||
*
|
||||
* Provider resolution decides whose configuration a base URL is, and the
|
||||
* client dials accordingly. Under `=true` a private endpoint is still reached
|
||||
* for the operator's own configurations (the instance-wide admin provider and
|
||||
* settings saved on an admin account), always through the pinned
|
||||
* operator-approved dispatcher; any other account's saved URL is pinned to
|
||||
* public hosts, so `safeFetch` refuses it. Checked at the wire: the options
|
||||
* each resolved provider hands to `safeFetch`.
|
||||
*/
|
||||
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,
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn(), update: vi.fn() },
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((v: string) => `decrypted:${v}`),
|
||||
encrypt: vi.fn((v: string) => `encrypted:${v}`),
|
||||
}));
|
||||
vi.mock("@/lib/ai/codex-oauth", () => ({
|
||||
refreshDeviceTokens: vi.fn(),
|
||||
encryptCodexCreds: vi.fn(),
|
||||
decryptCodexCreds: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => undefined),
|
||||
}));
|
||||
|
||||
import {
|
||||
AITestConfigError,
|
||||
resolveProvider,
|
||||
resolveProviderForTest,
|
||||
} from "../provider";
|
||||
import { _resetAiGrantsForTests } from "../local-host-allowlist";
|
||||
import { singleUserTurn } from "../types";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
function row(role: "ADMIN" | "USER", overrides: Record<string, unknown>) {
|
||||
return {
|
||||
aiProvider: null,
|
||||
aiModel: "m",
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
aiOpenaiKeyEncrypted: null,
|
||||
aiCompatBaseUrl: null,
|
||||
aiCompatKeyEncrypted: null,
|
||||
aiCompatModel: null,
|
||||
managedProfileAt: null,
|
||||
role,
|
||||
...overrides,
|
||||
} as never;
|
||||
}
|
||||
|
||||
async function dialOptions(): Promise<Record<string, unknown>> {
|
||||
const provider = await resolveProvider("u-1");
|
||||
await provider
|
||||
.generateCompletion(singleUserTurn({ system: "s", user: "u" }))
|
||||
.catch(() => undefined);
|
||||
return safeFetch.mock.calls[0][2] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
_resetAiGrantsForTests();
|
||||
safeFetch.mockReset();
|
||||
safeFetch.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({ choices: [{ message: { content: "ok" } }] }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
),
|
||||
);
|
||||
vi.mocked(prisma.user.findUnique).mockReset();
|
||||
vi.mocked(prisma.appSettings.findUnique).mockReset();
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
|
||||
vi.stubEnv("AI_PRIVATE_ORIGINS", "");
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("ALLOW_LOCAL_AI_PRIVATE_HOSTS=true at dial time", () => {
|
||||
it.each([
|
||||
["LOCAL", { aiProvider: "LOCAL", aiBaseUrl: "http://10.0.0.5:11434/v1" }],
|
||||
[
|
||||
"OPENAI_COMPATIBLE",
|
||||
{
|
||||
aiProvider: "OPENAI_COMPATIBLE",
|
||||
aiCompatBaseUrl: "http://10.0.0.5:4000/v1",
|
||||
aiCompatModel: "m",
|
||||
},
|
||||
],
|
||||
])(
|
||||
"an admin account's %s endpoint goes through the operator-approved pin",
|
||||
async (_label, overrides) => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
||||
row("ADMIN", overrides),
|
||||
);
|
||||
const opts = await dialOptions();
|
||||
expect(opts.requirePublicHost).toBe(false);
|
||||
expect(opts.operatorApprovedPrivateOrigin).toMatch(
|
||||
/^http:\/\/10\.0\.0\.5:/,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["LOCAL", { aiProvider: "LOCAL", aiBaseUrl: "http://10.0.0.5:11434/v1" }],
|
||||
[
|
||||
"OPENAI_COMPATIBLE",
|
||||
{
|
||||
aiProvider: "OPENAI_COMPATIBLE",
|
||||
aiCompatBaseUrl: "http://10.0.0.5:4000/v1",
|
||||
aiCompatModel: "m",
|
||||
},
|
||||
],
|
||||
])(
|
||||
"a non-admin account's %s endpoint stays on the public pin",
|
||||
async (_label, overrides) => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
||||
row("USER", overrides),
|
||||
);
|
||||
const opts = await dialOptions();
|
||||
expect(opts.requirePublicHost).toBe(true);
|
||||
expect(opts).not.toHaveProperty("operatorApprovedPrivateOrigin");
|
||||
},
|
||||
);
|
||||
|
||||
it("the metadata address stays on the public pin even for an admin account", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
||||
row("ADMIN", {
|
||||
aiProvider: "LOCAL",
|
||||
aiBaseUrl: "http://169.254.169.254/latest/v1",
|
||||
}),
|
||||
);
|
||||
const opts = await dialOptions();
|
||||
expect(opts.requirePublicHost).toBe(true);
|
||||
});
|
||||
|
||||
it("the instance-wide admin provider is the operator's own", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
||||
row("USER", { aiProvider: null }),
|
||||
);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
adminAiKeyEncrypted: "k",
|
||||
adminAiModel: "m",
|
||||
adminAiBaseUrl: "http://10.0.0.7:8080/v1",
|
||||
} as never);
|
||||
const opts = await dialOptions();
|
||||
expect(opts).toMatchObject({
|
||||
requirePublicHost: false,
|
||||
operatorApprovedPrivateOrigin: "http://10.0.0.7:8080",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("the connection test under ALLOW_LOCAL_AI_PRIVATE_HOSTS=true", () => {
|
||||
it("refuses a non-admin account's private endpoint", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
||||
row("USER", {
|
||||
aiProvider: "LOCAL",
|
||||
aiBaseUrl: "http://10.0.0.5:11434/v1",
|
||||
}),
|
||||
);
|
||||
await expect(resolveProviderForTest("u-1")).rejects.toBeInstanceOf(
|
||||
AITestConfigError,
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves an admin account's private endpoint", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
||||
row("ADMIN", {
|
||||
aiProvider: "LOCAL",
|
||||
aiBaseUrl: "http://10.0.0.5:11434/v1",
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
resolveProviderForTest("u-1", { provider: "LOCAL" }),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* v1.39.3 — what the deprecated `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` is
|
||||
* currently standing in for, for the admin AI settings page.
|
||||
*
|
||||
* Null when `true` is not set. Otherwise the distinct origins of every saved
|
||||
* AI base URL on the instance (each account's Local and gateway URL, and the
|
||||
* instance-wide admin base URL) that point at the operator's network and that
|
||||
* no `AI_PRIVATE_ORIGINS` or legacy host-list entry covers yet. Listing them is
|
||||
* the migration path: the operator copies them into `AI_PRIVATE_ORIGINS`, and
|
||||
* `=true` can go. A non-admin account's URL is included on purpose; `=true` no
|
||||
* longer covers it, so it is exactly the one that needs an entry.
|
||||
*
|
||||
* An origin is scheme, host and port, nothing more, so no path or credential
|
||||
* a person typed into a base URL reaches the page.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
legacyAnyHostConfigured,
|
||||
originNeedingAiGrant,
|
||||
} from "@/lib/ai/local-host-allowlist";
|
||||
|
||||
export interface LegacyPrivateHostReport {
|
||||
originsInUse: string[];
|
||||
}
|
||||
|
||||
export async function legacyPrivateHostReport(
|
||||
adminBaseUrl: string | null | undefined,
|
||||
): Promise<LegacyPrivateHostReport | null> {
|
||||
if (!legacyAnyHostConfigured()) return null;
|
||||
const rows = await prisma.user.findMany({
|
||||
where: {
|
||||
OR: [{ aiBaseUrl: { not: null } }, { aiCompatBaseUrl: { not: null } }],
|
||||
},
|
||||
select: { aiBaseUrl: true, aiCompatBaseUrl: true },
|
||||
});
|
||||
const origins = new Set<string>();
|
||||
for (const url of [
|
||||
adminBaseUrl,
|
||||
...rows.flatMap((r) => [r.aiBaseUrl, r.aiCompatBaseUrl]),
|
||||
]) {
|
||||
if (!url) continue;
|
||||
const origin = originNeedingAiGrant(url);
|
||||
if (origin) origins.add(origin);
|
||||
}
|
||||
return { originsInUse: [...origins].sort() };
|
||||
}
|
||||
@@ -27,6 +27,13 @@ interface LocalClientConfig {
|
||||
apiKey?: string | null;
|
||||
model: string;
|
||||
baseUrl: string;
|
||||
/**
|
||||
* v1.39.3 — true when the base URL belongs to the operator: the
|
||||
* instance-wide admin provider, or provider settings saved on an admin
|
||||
* account. Only such a URL is still covered by the deprecated
|
||||
* `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` (see `local-host-allowlist.ts`).
|
||||
*/
|
||||
operatorTrusted?: boolean;
|
||||
}
|
||||
|
||||
const STRICT_JSON_PREFIX =
|
||||
@@ -198,7 +205,9 @@ export class LocalOpenAICompatibleClient implements AIProvider {
|
||||
// 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 egress = aiEgressPolicyFor(url, {
|
||||
operatorTrusted: this.config.operatorTrusted,
|
||||
});
|
||||
const res = await safeFetch(
|
||||
url,
|
||||
{
|
||||
@@ -336,7 +345,9 @@ export class LocalOpenAICompatibleClient implements AIProvider {
|
||||
onDelta: (delta: string) => void,
|
||||
): Promise<CompletionResult> {
|
||||
const { url, headers, body } = this.buildRequest(params, true);
|
||||
const egress = aiEgressPolicyFor(url);
|
||||
const egress = aiEgressPolicyFor(url, {
|
||||
operatorTrusted: this.config.operatorTrusted,
|
||||
});
|
||||
|
||||
// Idle controller: aborts the upstream when no chunk arrives within the
|
||||
// per-idle window. Composed with the caller's cancel signal so a client
|
||||
|
||||
@@ -18,12 +18,20 @@
|
||||
* 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.
|
||||
* `ALLOW_LOCAL_AI_PRIVATE_HOSTS` (v1.18.7) stays readable. 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 used to open every private host to every user of the
|
||||
* instance. From v1.39.3 it is deprecated and narrowed for one release cycle
|
||||
* rather than removed, so a single-user self-host with Ollama on the LAN keeps
|
||||
* working after a patch update: it still grants a private host, but only to
|
||||
* the configurations the operator owns (the instance-wide admin provider, and
|
||||
* provider settings saved on an admin account), and only through the same
|
||||
* pinned dispatcher, so the metadata range, link-local and the unspecified
|
||||
* address stay unreachable. A non-admin account's own base URL needs an exact
|
||||
* origin in `AI_PRIVATE_ORIGINS`. The boot summary and the admin AI settings
|
||||
* page say so and name the origins in use.
|
||||
*/
|
||||
import { isIP } from "node:net";
|
||||
|
||||
@@ -32,15 +40,27 @@ import {
|
||||
originOfUrl,
|
||||
parsePrivateOrigins,
|
||||
} from "@/lib/private-origin-policy";
|
||||
import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
|
||||
const ORIGINS_ENV = "AI_PRIVATE_ORIGINS";
|
||||
const LEGACY_ENV = "ALLOW_LOCAL_AI_PRIVATE_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".`;
|
||||
`${LEGACY_ENV}=true is deprecated, set ${ORIGINS_ENV}=<origin> ` +
|
||||
`(for example ${ORIGINS_ENV}="http://ollama.lan:11434"); =true will be ` +
|
||||
`removed in a later release. Until then it only covers the admin AI ` +
|
||||
`provider and AI settings saved on an admin account.`;
|
||||
|
||||
/**
|
||||
* Who a base URL belongs to. `operatorTrusted` is true for the instance-wide
|
||||
* admin provider and for provider settings saved on an admin account: the
|
||||
* configurations the deprecated `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` still
|
||||
* covers. Everything else (any non-admin account's own base URL) is not.
|
||||
*/
|
||||
export interface AiUrlOwner {
|
||||
operatorTrusted?: boolean;
|
||||
}
|
||||
|
||||
interface Grants {
|
||||
origins: ReadonlySet<string>;
|
||||
@@ -67,6 +87,8 @@ export function legacyAnyHostConfigured(
|
||||
function parseLegacyHosts(raw: string | undefined): ReadonlySet<string> {
|
||||
const value = raw?.trim() ?? "";
|
||||
if (!value || value.toLowerCase() === "false") return new Set();
|
||||
// `true` is handled per call in `grantedAiOrigin` (operator-owned
|
||||
// configurations only); it is not a host list.
|
||||
if (value.toLowerCase() === "true") {
|
||||
warnOnce(LEGACY_ANY_HOST_WARNING);
|
||||
return new Set();
|
||||
@@ -117,7 +139,10 @@ function grants(): Grants {
|
||||
* legacy host list. A host no grant may name is never granted, whatever the
|
||||
* lists say.
|
||||
*/
|
||||
export function grantedAiOrigin(url: string): string | null {
|
||||
export function grantedAiOrigin(
|
||||
url: string,
|
||||
owner: AiUrlOwner = {},
|
||||
): 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();
|
||||
@@ -129,6 +154,16 @@ export function grantedAiOrigin(url: string): string | null {
|
||||
// dropped by the pinned dispatcher at dial time.
|
||||
if (origins.has(origin)) return origin;
|
||||
if (legacyHosts.has(hostname.replace(/^\[|\]$/g, ""))) return origin;
|
||||
// Deprecated `=true`: an operator-owned configuration only, and never a
|
||||
// literal no grant may name. A name that resolves into those ranges is
|
||||
// dropped by the pinned dispatcher at dial time.
|
||||
if (
|
||||
owner.operatorTrusted === true &&
|
||||
legacyAnyHostConfigured() &&
|
||||
!isNeverGrantableHost(hostname)
|
||||
) {
|
||||
return origin;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -137,8 +172,11 @@ export function grantedAiOrigin(url: string): string | null {
|
||||
* host be saved and called?", never "is it public?": callers still accept a
|
||||
* public URL without any grant.
|
||||
*/
|
||||
export function isLocalAiHostAllowed(url: string): boolean {
|
||||
return grantedAiOrigin(url) !== null;
|
||||
export function isLocalAiHostAllowed(
|
||||
url: string,
|
||||
owner: AiUrlOwner = {},
|
||||
): boolean {
|
||||
return grantedAiOrigin(url, owner) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,15 +187,52 @@ export function isLocalAiHostAllowed(url: string): boolean {
|
||||
*/
|
||||
export function aiEgressPolicyFor(
|
||||
url: string,
|
||||
owner: AiUrlOwner = {},
|
||||
):
|
||||
| { requirePublicHost: true }
|
||||
| { requirePublicHost: false; operatorApprovedPrivateOrigin: string } {
|
||||
const origin = grantedAiOrigin(url);
|
||||
const origin = grantedAiOrigin(url, owner);
|
||||
return origin === null
|
||||
? { requirePublicHost: true }
|
||||
: { requirePublicHost: false, operatorApprovedPrivateOrigin: origin };
|
||||
}
|
||||
|
||||
/**
|
||||
* Host names that only resolve on a local network. `isPublicUrl` judges the
|
||||
* text of a name, not where it resolves, so `ollama.lan` reads as public to it
|
||||
* while the connect-time pin would refuse it.
|
||||
*/
|
||||
const LAN_SUFFIXES = [
|
||||
".lan",
|
||||
".local",
|
||||
".home",
|
||||
".home.arpa",
|
||||
".internal",
|
||||
".intranet",
|
||||
".localdomain",
|
||||
".corp",
|
||||
];
|
||||
|
||||
/**
|
||||
* The origin an operator would add to `AI_PRIVATE_ORIGINS` for this saved
|
||||
* base URL, or null when it needs none: a hosted endpoint, an address no grant
|
||||
* can open, an origin the exact list or the legacy host list already covers,
|
||||
* or an unparseable value. Used by the admin AI settings page to name what the
|
||||
* deprecated `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` is currently standing in for.
|
||||
*/
|
||||
export function originNeedingAiGrant(url: string): string | null {
|
||||
const origin = originOfUrl(url);
|
||||
if (!origin) return null;
|
||||
const hostname = new URL(origin).hostname.toLowerCase();
|
||||
if (isNeverGrantableHost(hostname)) return null;
|
||||
const lanName =
|
||||
!hostname.includes(".") ||
|
||||
LAN_SUFFIXES.some((suffix) => hostname.endsWith(suffix));
|
||||
if (isPublicUrl(url) && !lanName) return null;
|
||||
if (grantedAiOrigin(url) !== null) return null;
|
||||
return origin;
|
||||
}
|
||||
|
||||
/** Test helper: forget the parsed grants and the warnings already printed. */
|
||||
export function _resetAiGrantsForTests(): void {
|
||||
cachedKey = null;
|
||||
|
||||
@@ -46,6 +46,13 @@ interface OpenAIClientConfig {
|
||||
* `codex` keeps the fully pinned posture.
|
||||
*/
|
||||
providerType?: OpenAIProviderType;
|
||||
/**
|
||||
* v1.39.3 — true when the base URL belongs to the operator: the
|
||||
* instance-wide admin provider, or provider settings saved on an admin
|
||||
* account. Only such a URL is still covered by the deprecated
|
||||
* `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` (see `local-host-allowlist.ts`).
|
||||
*/
|
||||
operatorTrusted?: boolean;
|
||||
}
|
||||
|
||||
type OpenAIProviderType = "admin-key" | "codex" | "openai-compatible";
|
||||
@@ -169,7 +176,9 @@ export class OpenAIClient implements AIProvider {
|
||||
{
|
||||
timeoutMs: params.timeoutMs ?? 60_000,
|
||||
...(this.isGateway || this.type === "admin-key"
|
||||
? aiEgressPolicyFor(url)
|
||||
? aiEgressPolicyFor(url, {
|
||||
operatorTrusted: this.config.operatorTrusted,
|
||||
})
|
||||
: { requirePublicHost: true as const }),
|
||||
signal: params.signal,
|
||||
},
|
||||
|
||||
+32
-2
@@ -55,8 +55,20 @@ type UserAIRow = {
|
||||
aiCompatBaseUrl: string | null;
|
||||
aiCompatKeyEncrypted: string | null;
|
||||
aiCompatModel: string | null;
|
||||
/**
|
||||
* v1.39.3 — the owning account's role. Provider settings saved on an admin
|
||||
* account are the operator's own, and only those (plus the instance-wide
|
||||
* admin provider) are still covered by the deprecated
|
||||
* `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true`.
|
||||
*/
|
||||
role: string;
|
||||
};
|
||||
|
||||
/** v1.39.3 — see `UserAIRow.role`. */
|
||||
function isOperatorOwned(row: { role: string } | null | undefined): boolean {
|
||||
return row?.role === "ADMIN";
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.33.1 (#470) — build the OpenAI-compatible gateway client from the user's
|
||||
* three dedicated columns. The signature names every column this provider is
|
||||
@@ -83,6 +95,7 @@ function buildCompatProvider(row: {
|
||||
aiCompatBaseUrl: string | null;
|
||||
aiCompatKeyEncrypted: string | null;
|
||||
aiCompatModel: string | null;
|
||||
role: string;
|
||||
}): AIProvider | null {
|
||||
const model = row.aiCompatModel?.trim() || row.aiModel?.trim() || null;
|
||||
if (!row.aiCompatBaseUrl || !model) return null;
|
||||
@@ -91,6 +104,7 @@ function buildCompatProvider(row: {
|
||||
model,
|
||||
baseUrl: row.aiCompatBaseUrl,
|
||||
providerType: "openai-compatible",
|
||||
operatorTrusted: isOperatorOwned(row),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -122,6 +136,7 @@ function buildUserProvider(row: UserAIRow): AIProvider | null {
|
||||
: null,
|
||||
model: row.aiModel ?? "local-model",
|
||||
baseUrl: row.aiBaseUrl,
|
||||
operatorTrusted: isOperatorOwned(row),
|
||||
});
|
||||
}
|
||||
case "OPENAI_COMPATIBLE": {
|
||||
@@ -212,6 +227,8 @@ async function resolveAdminProvider(): Promise<AIProvider> {
|
||||
apiKey: decrypt(settings.adminAiKeyEncrypted),
|
||||
model: settings.adminAiModel ?? "gpt-4o",
|
||||
baseUrl,
|
||||
// The instance-wide provider is the operator's own configuration.
|
||||
operatorTrusted: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -480,6 +497,7 @@ export async function resolveProvider(userId: string): Promise<AIProvider> {
|
||||
aiCompatBaseUrl: true,
|
||||
aiCompatKeyEncrypted: true,
|
||||
aiCompatModel: true,
|
||||
role: true,
|
||||
managedProfileAt: true,
|
||||
},
|
||||
});
|
||||
@@ -548,6 +566,7 @@ export async function resolveProviderChain(
|
||||
aiCompatBaseUrl: true,
|
||||
aiCompatKeyEncrypted: true,
|
||||
aiCompatModel: true,
|
||||
role: true,
|
||||
aiProviderChain: true,
|
||||
useCentralCodex: true,
|
||||
managedProfileAt: true,
|
||||
@@ -1023,6 +1042,7 @@ async function resolveProviderForType(
|
||||
aiCompatBaseUrl: string | null;
|
||||
aiCompatKeyEncrypted: string | null;
|
||||
aiCompatModel: string | null;
|
||||
role: string;
|
||||
} | null;
|
||||
policy: ProviderCredentialPolicy;
|
||||
},
|
||||
@@ -1062,6 +1082,7 @@ async function resolveProviderForType(
|
||||
: null,
|
||||
model: ctx.userRow.aiModel ?? "local-model",
|
||||
baseUrl: ctx.userRow.aiBaseUrl,
|
||||
operatorTrusted: isOperatorOwned(ctx.userRow),
|
||||
});
|
||||
}
|
||||
case "openai-compatible": {
|
||||
@@ -1138,8 +1159,12 @@ export async function resolveProviderForTest(
|
||||
aiCompatBaseUrl: true,
|
||||
aiCompatKeyEncrypted: true,
|
||||
aiCompatModel: true,
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
// v1.39.3 — an unsaved override is tested under the same owner rule as a
|
||||
// saved one: it belongs to the calling account.
|
||||
const owner = { operatorTrusted: isOperatorOwned(stored) };
|
||||
|
||||
const provider = (override.provider ?? stored?.aiProvider ?? "")
|
||||
.toString()
|
||||
@@ -1191,7 +1216,7 @@ export async function resolveProviderForTest(
|
||||
}
|
||||
// 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);
|
||||
const allowPrivate = isLocalAiHostAllowed(baseUrl, owner);
|
||||
if (!allowPrivate && !isPublicUrl(baseUrl)) {
|
||||
throw new AITestConfigError(
|
||||
422,
|
||||
@@ -1207,6 +1232,7 @@ export async function resolveProviderForTest(
|
||||
apiKey,
|
||||
model: model || "local-model",
|
||||
baseUrl,
|
||||
...owner,
|
||||
});
|
||||
}
|
||||
case "OPENAI_COMPATIBLE": {
|
||||
@@ -1227,7 +1253,10 @@ export async function resolveProviderForTest(
|
||||
"OpenAI-compatible provider requires a base URL",
|
||||
);
|
||||
}
|
||||
if (!isLocalAiHostAllowed(compatBaseUrl) && !isPublicUrl(compatBaseUrl)) {
|
||||
if (
|
||||
!isLocalAiHostAllowed(compatBaseUrl, owner) &&
|
||||
!isPublicUrl(compatBaseUrl)
|
||||
) {
|
||||
throw new AITestConfigError(
|
||||
422,
|
||||
"Base URL points to an internal/private host",
|
||||
@@ -1253,6 +1282,7 @@ export async function resolveProviderForTest(
|
||||
model: compatModel,
|
||||
baseUrl: compatBaseUrl,
|
||||
providerType: "openai-compatible",
|
||||
...owner,
|
||||
});
|
||||
}
|
||||
case "CHATGPT_OAUTH": {
|
||||
|
||||
@@ -126,11 +126,11 @@ 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) ----------------------
|
||||
// --- Deprecated 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.
|
||||
// the network to every user's AI base URL. Since v1.39.3 it only covers the
|
||||
// operator's own AI configurations and is on its way out; this line names
|
||||
// the replacement. The admin AI settings page lists the origins in use.
|
||||
if (legacyAnyHostConfigured(env)) {
|
||||
lines.push({
|
||||
label: "ALLOW_LOCAL_AI_PRIVATE_HOSTS",
|
||||
|
||||
Reference in new issue
Block a user