mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +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,
|
||||
|
||||
Reference in new issue
Block a user