mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
fix(security): grant private AI endpoints by exact origin, dialled pinned
ALLOW_LOCAL_AI_PRIVATE_HOSTS=true let any user of the instance point the Local provider or the OpenAI-compatible gateway at any private address the server could reach, the metadata range included, and a granted host was dialled with no connect-time pin at all. AI_PRIVATE_ORIGINS grants a private AI endpoint with the exact-origin grammar NOTIFICATION_PRIVATE_ORIGINS and NIGHTSCOUT_PRIVATE_ORIGINS use. A granted call goes through the pinned operator-approved dispatcher: redirects forbidden, and metadata, link-local and unspecified answers dropped at dial time even for a granted name. Everything else stays on the public pin. The Local client (buffered and streaming) and the gateway and admin-key tags of the OpenAI client all take their policy from one function. The host-list form of ALLOW_LOCAL_AI_PRIVATE_HOSTS keeps working with the same pinning; a never-grantable literal or a host:port entry is refused with a one-time warning. Its "true" form grants nothing any more and is reported in the boot readiness summary with the replacement. A failed lab-report scan no longer puts the endpoint's response body into the wide event; status and model stay. The response to the caller never carried it.
This commit is contained in:
1 parent
f7b3ce6ee0
commit
1a674cf4b0
20 files changed
+508
-181
No files matched your search
@@ -66,6 +66,7 @@ import { rasterizePdf } from "@/lib/documents/rasterize-pdf";
|
||||
import { reserveBudget, reconcileSpend } from "@/lib/ai/coach/budget";
|
||||
import { checkRateLimit, refundRateLimit } from "@/lib/rate-limit";
|
||||
import { OcrExtractError, runOcrExtraction } from "@/lib/labs/ocr-extract";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -164,6 +165,29 @@ describe("POST /api/labs/ocr/extract — text mode budget", () => {
|
||||
expect(res.status).toBe(502);
|
||||
expect(body.error).toContain("configured AI provider");
|
||||
});
|
||||
|
||||
it("never passes the upstream body on, to the caller or to the event", async () => {
|
||||
const upstreamBody = "ami-id instance-id iam/security-credentials/admin";
|
||||
vi.mocked(runOcrExtraction).mockRejectedValue(
|
||||
Object.assign(new Error("Local AI request failed (403)"), {
|
||||
httpStatus: 403,
|
||||
model: "llama3",
|
||||
bodyExcerpt: upstreamBody,
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await POST(textReq());
|
||||
expect(res.status).toBe(502);
|
||||
expect(await res.text()).not.toContain("iam/security-credentials");
|
||||
const events = JSON.stringify(vi.mocked(annotate).mock.calls);
|
||||
expect(events).not.toContain("iam/security-credentials");
|
||||
// The status and the model stay, so an operator can still tell why.
|
||||
expect(annotate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
meta: expect.objectContaining({ upstreamStatus: 403, model: "llama3" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/labs/ocr/extract — vision PDF rasterization", () => {
|
||||
|
||||
@@ -72,11 +72,16 @@ export const dynamic = "force-dynamic";
|
||||
/** OCR'd text is bounded in the schema; cap the JSON body proportionally. */
|
||||
const TEXT_BODY_MAX_BYTES = 512 * 1024;
|
||||
|
||||
/**
|
||||
* The wide-event facts of a failed provider call: the status and the model,
|
||||
* never the upstream body. The base URL can be one a user typed, and what an
|
||||
* arbitrary endpoint answers has no business in the event stream (v1.39.3);
|
||||
* the response to the caller carries neither.
|
||||
*/
|
||||
function providerFailureMeta(error: unknown, mode?: "text") {
|
||||
const err = error as {
|
||||
httpStatus?: unknown;
|
||||
model?: unknown;
|
||||
bodyExcerpt?: unknown;
|
||||
};
|
||||
return {
|
||||
reason: "provider_error",
|
||||
@@ -85,9 +90,6 @@ function providerFailureMeta(error: unknown, mode?: "text") {
|
||||
? { upstreamStatus: err.httpStatus }
|
||||
: {}),
|
||||
...(typeof err.model === "string" ? { model: err.model } : {}),
|
||||
...(typeof err.bodyExcerpt === "string"
|
||||
? { upstreamError: err.bodyExcerpt }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -197,14 +197,14 @@ 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.
|
||||
// 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);
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -228,7 +228,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
// here as much as it does for Ollama.
|
||||
if (!isLocalAiHostAllowed(trimmed) && !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,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user