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:
Marc-André Bombeck committed 2026-09-26 20:59:10 +02:00
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
View File
@@ -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,
),
});
});