From fb829540771e6f55c33ba2336917a84337bf518b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 27 Aug 2026 08:48:00 +0200 Subject: [PATCH] Let the operator provider reach an allowlisted private host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../openai-compatible-client.test.ts | 52 ++++++++++++++++--- src/lib/ai/openai-client.ts | 35 +++++++++---- 2 files changed, 68 insertions(+), 19 deletions(-) diff --git a/src/lib/ai/__tests__/openai-compatible-client.test.ts b/src/lib/ai/__tests__/openai-compatible-client.test.ts index dfac04cbb..653ff5181 100644 --- a/src/lib/ai/__tests__/openai-compatible-client.test.ts +++ b/src/lib/ai/__tests__/openai-compatible-client.test.ts @@ -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", () => { diff --git a/src/lib/ai/openai-client.ts b/src/lib/ai/openai-client.ts index 6cccde128..7e585896e 100644 --- a/src/lib/ai/openai-client.ts +++ b/src/lib/ai/openai-client.ts @@ -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, }, );