Merge branch 'fix/encrypt-free-text-and-local-ai' into release/v1.39.3

# Conflicts:
#	src/app/api/import/csv/route.ts
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 21:50:05 +02:00
commit 66b1aef40c
69 files changed
+3625 -367

No files matched your search

@@ -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);
});
});
+29 -8
View File
@@ -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) {
@@ -197,14 +217,15 @@ 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.
const allowPrivate = isLocalAiHostAllowed(trimmed);
// metadata endpoint or internal admin panels. The operator grants a
// private endpoint by exact origin (`AI_PRIVATE_ORIGINS`, or the legacy
// 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. 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,
);
}
@@ -226,9 +247,9 @@ 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. 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,
);
}