mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
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:
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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user