test(ai): pin the gateway provider's isolation and host policy

The properties this provider has to keep: it resolves against the base
URL that was saved for it, the pinned OpenAI arm cannot see that URL and
still posts to api.openai.com, and the OpenAI key never travels to the
gateway. Each is asserted at the wire, not on internals.

The SSRF floor is checked through the same alt-notation classes the
Local provider is checked against, and the pinned tags are shown to keep
requiring a public host even when the operator opened every private one.

The AI settings connect-src is pinned to what it already allowed: the
gateway is called server-side, so no CSP change is needed, and a future
change that needs one will turn this red.
This commit is contained in:
Marc-André Bombeck committed 2026-07-26 22:27:48 +02:00
1 parent ee620b2707
commit 3ef6a7119b
5 files changed
+852

No files matched your search

@@ -261,6 +261,27 @@ describe("POST /api/ai/test — dropdown-aware override", () => {
expect(body.data.model).toBe("claude-3-5-sonnet-latest");
});
it("forwards the gateway's own override fields (#470)", async () => {
makeProviderThatSucceeds("anthropic/claude-sonnet-4-6");
const response = await POST(
jsonRequest({
provider: "OPENAI_COMPATIBLE",
compatBaseUrl: "https://litellm.example.com/v1",
compatKey: "gateway-secret",
compatModel: "anthropic/claude-sonnet-4-6",
}) as never,
);
expect(response.status).toBe(200);
// The gateway's fields travel under their own names — the pinned OpenAI
// arm reads `baseUrl` / `openaiKey` and must not be reachable from here.
expect(resolveProviderForTest).toHaveBeenCalledWith("u-1", {
provider: "OPENAI_COMPATIBLE",
compatBaseUrl: "https://litellm.example.com/v1",
compatKey: "gateway-secret",
compatModel: "anthropic/claude-sonnet-4-6",
});
});
it("rejects unknown provider values with 422", async () => {
const response = await POST(
jsonRequest({ provider: "MALICIOUS" }) as never,
@@ -46,6 +46,8 @@ vi.mock("@/lib/logging/context", () => ({
import { GET, PATCH } from "../route";
import { prisma } from "@/lib/db";
import { resolveProviderAvailability } from "@/lib/ai/provider";
import { isPublicUrl } from "@/lib/validations/notifications";
import { isLocalAiHostAllowed } from "@/lib/ai/local-host-allowlist";
function patchRequest(body: unknown): Request {
return new Request("http://localhost/api/user/ai-provider", {
@@ -192,3 +194,110 @@ describe("PATCH /api/user/ai-provider — v1.22 fields", () => {
);
});
});
// v1.33.1 (#470) — the OpenAI-compatible gateway writes three columns of its
// own. What matters at this layer: the SSRF floor applies to the gateway's
// base URL exactly as it does to the Local one, and the column is nobody
// else's — switching the active provider away must not hand a stale URL to a
// key-carrying provider, and must not silently discard the user's gateway
// either.
describe("PATCH /api/user/ai-provider — the gateway's own columns (#470)", () => {
const patch = PATCH as (req: Request) => Promise<Response>;
it("persists base URL, model and encrypted key", async () => {
vi.mocked(isPublicUrl).mockReturnValue(true);
const res = await patch(
patchRequest({
provider: "OPENAI_COMPATIBLE",
compatBaseUrl: "https://litellm.example.com/v1",
compatModel: "anthropic/claude-sonnet-4-6",
compatKey: "gateway-secret",
}),
);
expect(res.status).toBe(200);
expect(vi.mocked(prisma.user.update)).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
aiProvider: "OPENAI_COMPATIBLE",
aiCompatBaseUrl: "https://litellm.example.com/v1",
aiCompatModel: "anthropic/claude-sonnet-4-6",
aiCompatKeyEncrypted: "enc:gateway-secret",
}),
}),
);
});
it("refuses a private base URL the operator has not allowlisted", async () => {
vi.mocked(isPublicUrl).mockReturnValue(false);
vi.mocked(isLocalAiHostAllowed).mockReturnValue(false);
const res = await patch(
patchRequest({ compatBaseUrl: "http://169.254.169.254/v1" }),
);
expect(res.status).toBe(422);
expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled();
});
it("accepts a private base URL the operator allowlisted", async () => {
vi.mocked(isPublicUrl).mockReturnValue(false);
vi.mocked(isLocalAiHostAllowed).mockReturnValue(true);
const res = await patch(
patchRequest({ compatBaseUrl: "http://litellm.lan:4000/v1" }),
);
expect(res.status).toBe(200);
expect(vi.mocked(prisma.user.update)).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
aiCompatBaseUrl: "http://litellm.lan:4000/v1",
}),
}),
);
});
it("leaves the gateway URL alone when the provider switches to OpenAI", async () => {
vi.mocked(isPublicUrl).mockReturnValue(true);
await patch(patchRequest({ provider: "OPENAI" }));
const data = vi.mocked(prisma.user.update).mock.calls[0][0].data as Record<
string,
unknown
>;
// `aiBaseUrl` is the shared LOCAL column and IS cleared. The gateway's is
// untouched — it belongs to one provider, and that provider is not this
// one, so there is nothing for a stale value to redirect.
expect(data.aiBaseUrl).toBeNull();
expect(data).not.toHaveProperty("aiCompatBaseUrl");
});
});
describe("GET /api/user/ai-provider — the gateway's fields (#470)", () => {
it("reports the base URL and model, and the key as presence only", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
aiProvider: "OPENAI_COMPATIBLE",
aiModel: null,
aiBaseUrl: null,
aiAnthropicKeyEncrypted: null,
aiLocalKeyEncrypted: null,
aiOpenaiKeyEncrypted: null,
aiCompatBaseUrl: "https://litellm.example.com/v1",
aiCompatKeyEncrypted: "enc-gateway",
aiCompatModel: "anthropic/claude-sonnet-4-6",
aiResponseTimeoutSeconds: null,
} as never);
vi.mocked(resolveProviderAvailability).mockResolvedValue({
aiAvailable: true,
managedBy: "user",
});
const res = await (GET as () => Promise<Response>)();
const body = (await res.json()) as {
data: {
compatBaseUrl: string | null;
compatModel: string | null;
hasCompatKey: boolean;
};
};
expect(body.data.compatBaseUrl).toBe("https://litellm.example.com/v1");
expect(body.data.compatModel).toBe("anthropic/claude-sonnet-4-6");
expect(body.data.hasCompatKey).toBe(true);
expect(JSON.stringify(body)).not.toContain("enc-gateway");
});
});