mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Let the operator provider reach an allowlisted private host
The global admin provider's base URL is operator-typed in the admin settings, but the client pinned requirePublicHost unconditionally for its tag — so an operator pointing it at a private OpenAI-compatible proxy (an OAuth sidecar on the instance's own Docker network) got a safeFetch refusal on every call, indistinguishable from an outage. The admin-key tag now consults the same ALLOW_LOCAL_AI_PRIVATE_HOSTS policy as the user-configured gateway: a public URL never consults the allowlist, a private host without an entry is refused exactly as before, and both ends of the grant are the operator's own. The codex tag stays hard-pinned; its base URL is a repository constant. Every non-operator admin-key construction pins the public api.openai.com constant, so the personal-key posture is unchanged. The test asserting the old blanket pin is split into the three cases that actually differ: allowlisted private host passes, unlisted private host still refuses, codex refuses even a blanket allowlist.
This commit is contained in:
1 parent
d5d9793586
commit
fb82954077
2 files changed
+68
-19
No files matched your search
@@ -9,9 +9,11 @@ import { singleUserTurn } from "../types";
|
||||
* Two properties carry the security weight of this provider and are pinned
|
||||
* here rather than left to review:
|
||||
*
|
||||
* 1. The pinned `admin-key` / `codex` tags keep `requirePublicHost: true`
|
||||
* no matter what the operator allowlisted. Only the gateway tag — whose
|
||||
* base URL a person typed — consults the allowlist.
|
||||
* 1. The `codex` tag keeps `requirePublicHost: true` no matter what the
|
||||
* operator allowlisted — its base URL is a repository constant. The
|
||||
* gateway and `admin-key` tags — the two whose base URL a person can
|
||||
* type — consult the allowlist, and without an allowlist entry a
|
||||
* private host is refused exactly as before (v1.37.30).
|
||||
* 2. The SSRF floor under the gateway is `isPublicUrl`, which is the same
|
||||
* floor the Local provider sits on, including every IPv4 / IPv6
|
||||
* alt-notation class.
|
||||
@@ -168,12 +170,32 @@ describe("OpenAI-compatible gateway — host policy", () => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps the pinned tags on requirePublicHost even when the operator allowlisted the host", async () => {
|
||||
// v1.37.30 — the admin-key tag joined the allowlist policy: its base URL
|
||||
// is operator-typed in exactly one place (the global provider in the admin
|
||||
// settings, e.g. pointed at a private OpenAI-compatible OAuth proxy), and
|
||||
// the allowlist is the operator's own grant. The floor is unchanged: a
|
||||
// private host WITHOUT an allowlist entry is refused, and the codex tag
|
||||
// stays hard-pinned because its base URL is a repository constant. Note
|
||||
// that every non-operator admin-key construction in the app pins the
|
||||
// public `api.openai.com` constant, so the personal-key posture cannot
|
||||
// reach this branch.
|
||||
it("admin-key consults the allowlist for an operator-typed private host", async () => {
|
||||
const fetchMock = okReply();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "openai-oauth-proxy");
|
||||
|
||||
await new OpenAIClient({
|
||||
apiKey: "unused-behind-proxy",
|
||||
model: "gpt-4o",
|
||||
baseUrl: "http://openai-oauth-proxy:10531/v1",
|
||||
}).generateCompletion(jsonTurn());
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("admin-key still refuses a private host the operator did not allowlist", async () => {
|
||||
vi.stubGlobal("fetch", okReply());
|
||||
// The operator opened every private host for their local AI endpoint.
|
||||
// That opt-in belongs to the gateway/local surface; a client carrying an
|
||||
// OpenAI-issued key must not inherit it.
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "");
|
||||
|
||||
await expect(
|
||||
new OpenAIClient({
|
||||
@@ -183,6 +205,20 @@ describe("OpenAI-compatible gateway — host policy", () => {
|
||||
}).generateCompletion(jsonTurn()),
|
||||
).rejects.toMatchObject({ kind: "private_host" });
|
||||
});
|
||||
|
||||
it("codex stays pinned to public hosts even under a blanket allowlist", async () => {
|
||||
vi.stubGlobal("fetch", okReply());
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
|
||||
|
||||
await expect(
|
||||
new OpenAIClient({
|
||||
apiKey: "codex-access-token",
|
||||
model: "gpt-5.4",
|
||||
baseUrl: "http://192.168.1.5:4000/v1",
|
||||
providerType: "codex",
|
||||
}).generateCompletion(jsonTurn()),
|
||||
).rejects.toMatchObject({ kind: "private_host" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenAI-compatible gateway — JSON-mode dialect", () => {
|
||||
|
||||
+24
-11
@@ -35,11 +35,13 @@ interface OpenAIClientConfig {
|
||||
* to log as "codex" instead.
|
||||
*
|
||||
* v1.33.1 (#470) — `openai-compatible` is the user-configured gateway
|
||||
* (LiteLLM / OpenRouter / vLLM). It is the ONLY tag whose `baseUrl` is not
|
||||
* a HealthLog-pinned constant, and the two behaviours below hang off it:
|
||||
* the operator's private-host allowlist may apply, and the JSON-mode
|
||||
* dialect is learned per endpoint instead of assumed. Every other tag
|
||||
* keeps the pinned `api.openai.com` posture unchanged.
|
||||
* (LiteLLM / OpenRouter / vLLM). Its `baseUrl` always comes from a person,
|
||||
* and two behaviours hang off that: the operator's private-host allowlist
|
||||
* may apply, and the JSON-mode dialect is learned per endpoint instead of
|
||||
* assumed. `admin-key` carries a person-typed base URL in exactly one
|
||||
* place (the operator's global provider in the admin settings) and shares
|
||||
* the allowlist policy for it since v1.37.30; the dialect stays assumed.
|
||||
* `codex` keeps the fully pinned posture.
|
||||
*/
|
||||
providerType?: OpenAIProviderType;
|
||||
}
|
||||
@@ -148,14 +150,25 @@ export class OpenAIClient implements AIProvider {
|
||||
// v1.21.5 — honour the caller's per-request timeout override; default 60 s.
|
||||
// v1.33.1 (#470) — the gateway tag reuses the Local provider's host
|
||||
// policy verbatim: public hosts always; a private host only when the
|
||||
// operator allowlisted it via ALLOW_LOCAL_AI_PRIVATE_HOSTS. Every other
|
||||
// tag stays pinned to `requirePublicHost: true` — `api.openai.com` is
|
||||
// public and there is nothing to opt into.
|
||||
// operator allowlisted it via ALLOW_LOCAL_AI_PRIVATE_HOSTS.
|
||||
//
|
||||
// v1.37.30 — the `admin-key` tag consults the same policy. Its base URL
|
||||
// is the one other operator-typed URL in this client (an operator can
|
||||
// point the global provider at a private OpenAI-compatible proxy, e.g.
|
||||
// an OAuth sidecar), and both ends of the grant are the operator's own:
|
||||
// the URL comes from the admin settings, the allowlist from the
|
||||
// operator's environment. Every non-operator construction of this tag
|
||||
// pins the public `api.openai.com` constant, for which the policy
|
||||
// resolves to `true` without ever consulting the allowlist — so the
|
||||
// personal-key posture is unchanged. The `codex` tag keeps the hard
|
||||
// pin: its base URL is a repository constant, there is nothing an
|
||||
// operator legitimately redirects.
|
||||
{
|
||||
timeoutMs: params.timeoutMs ?? 60_000,
|
||||
requirePublicHost: this.isGateway
|
||||
? requirePublicHostFor(this.config.baseUrl)
|
||||
: true,
|
||||
requirePublicHost:
|
||||
this.isGateway || this.type === "admin-key"
|
||||
? requirePublicHostFor(this.config.baseUrl)
|
||||
: true,
|
||||
signal: params.signal,
|
||||
},
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user