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:
Marc-André Bombeck committed 2026-08-27 08:48:00 +02:00
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
View File
@@ -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,
},
);