fix(sse): stop unhydrated compatible connections routing to the real OpenAI/Anthropic API (#13452)

Root cause: BaseExecutor/DefaultExecutor.buildUrl() silently defaulted an
openai-compatible-*/anthropic-compatible-* connection to the real
OpenAI/Anthropic API when providerSpecificData.baseUrl was absent, shipping
the connection's own stored credential to a public third party. baseUrl is
only ever stamped at write time by two routes; a connection created outside
those (direct DB insert, pre-hydration row, node-update race) reproduced
this bug.

Fix: buildUrl() now throws instead of defaulting (requireCompatibleBaseUrl
in providerRegistry.ts), and the credential-selection read path
(auth.ts -> compatibleNodeBaseUrl.ts) self-heals by re-joining
provider_nodes via the existing cache before a request ever reaches the
executor.

Regression test: tests/unit/issue-13452-node-baseurl-ignored.test.ts
This commit is contained in:
diegosouzapw
2026-09-15 17:31:06 -03:00
parent 3266d163f4
commit 94f1b3eb5b
8 changed files with 196 additions and 40 deletions

View File

@@ -0,0 +1 @@
- **fix(sse):** stop an unhydrated `openai-compatible-*`/`anthropic-compatible-*` connection from silently routing chat requests (and its stored credential) to the real OpenAI/Anthropic API instead of the operator's configured provider-node endpoint (#13452) — thanks @DenXio101

View File

@@ -319,3 +319,24 @@ export function getClaudeCodeDefaultModels(): {
haiku: find(/haiku/i),
};
}
/**
* #13452: shared guard for `*-compatible-*` executors' `buildUrl()`.
* `BaseExecutor`/`DefaultExecutor` used to default an `openai-compatible-*`
* / `anthropic-compatible-*` connection to the real OpenAI/Anthropic API
* when `credentials.providerSpecificData.baseUrl` was absent — silently
* shipping the connection's own stored "API key" as a Bearer/x-api-key
* token to a public third party instead of the operator's intended
* local/self-hosted endpoint. `provider` is embedded in the thrown error
* only for operator debuggability — it is never sent upstream.
*/
export function requireCompatibleBaseUrl(
provider: string | null | undefined,
providerSpecificData: { baseUrl?: unknown } | null | undefined
): string {
const baseUrl = providerSpecificData?.baseUrl;
if (typeof baseUrl === "string" && baseUrl) return baseUrl;
throw new Error(
`provider node "${provider}" has no baseUrl — node missing or connection not hydrated`
);
}

View File

@@ -1,5 +1,5 @@
import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts";
import { getRegistryEntry } from "../config/providerRegistry.ts";
import { getRegistryEntry, requireCompatibleBaseUrl } from "../config/providerRegistry.ts";
import { resolveFetchStartTimeout } from "../utils/fetchStartTimeoutPolicy.ts";
import {
resolveAlternateFormat,
@@ -30,7 +30,7 @@ import {
addParamToBlocklist,
isAutoLearnGloballyEnabled,
} from "@/lib/db/paramFilters";
import { applyFingerprint, isCliCompatEnabled, stripInternalBodyFields } from "../config/cliFingerprints.ts";
import { applyFingerprint, isCliCompatEnabled, stripInternalBodyFields } from "../config/cliFingerprints.ts"; // prettier-ignore
import { supportsClaudeMaxEffort, supportsXHighEffort } from "../config/providerModels.ts";
import { getThinkingBudgetConfig, ThinkingMode } from "../services/thinkingBudget.ts";
import {
@@ -380,7 +380,7 @@ export class BaseExecutor {
void stream;
if (this.provider?.startsWith?.("openai-compatible-")) {
const psd = credentials?.providerSpecificData;
const baseUrl = typeof psd?.baseUrl === "string" ? psd.baseUrl : "https://api.openai.com/v1";
const baseUrl = requireCompatibleBaseUrl(this.provider, psd); // #13452
const normalized = baseUrl.replace(/\/$/, "");
// Sanitize custom path: must start with '/', no path traversal, no null bytes
const rawPath = typeof psd?.chatPath === "string" && psd.chatPath ? psd.chatPath : null;

View File

@@ -11,7 +11,7 @@ import {
joinClaudeCodeCompatibleUrl,
} from "../services/claudeCodeCompatible.ts";
import { getGigachatAccessToken } from "../services/gigachatAuth.ts";
import { getRegistryEntry } from "../config/providerRegistry.ts";
import { getRegistryEntry, requireCompatibleBaseUrl } from "../config/providerRegistry.ts";
import { getModelTargetFormat } from "../config/providerModels.ts";
import {
mergeClientAnthropicBeta,
@@ -231,7 +231,7 @@ export class DefaultExecutor extends BaseExecutor {
void urlIndex;
if (this.provider?.startsWith?.("openai-compatible-")) {
const psd = credentials?.providerSpecificData;
const baseUrl = psd?.baseUrl || "https://api.openai.com/v1";
const baseUrl = requireCompatibleBaseUrl(this.provider, psd); // #13452
const normalized = baseUrl.replace(/\/$/, "");
const customPath = typeof psd?.chatPath === "string" && psd.chatPath ? psd.chatPath : null;
if (customPath) return `${normalized}${customPath}`;
@@ -244,7 +244,7 @@ export class DefaultExecutor extends BaseExecutor {
}
if (this.provider?.startsWith?.("anthropic-compatible-")) {
const psd = credentials?.providerSpecificData;
const baseUrl = psd?.baseUrl || "https://api.anthropic.com/v1";
const baseUrl = requireCompatibleBaseUrl(this.provider, psd); // #13452
const customPath = typeof psd?.chatPath === "string" && psd.chatPath ? psd.chatPath : null;
if (isClaudeCodeCompatible(this.provider)) {
return joinClaudeCodeCompatibleUrl(

View File

@@ -1,5 +1,6 @@
import { randomUUID } from "crypto";
import { nodeTypeFromId } from "@/lib/db/providerNodeSelect";
import { hydrateConnectionProviderSpecificData } from "./compatibleNodeBaseUrl.ts"; // #13452
import { extractGoogApiKeyHeader } from "./googApiKeyAuth.ts";
import { describeUpstreamFailure } from "@/shared/utils/upstreamError";
import { buildAllExpiredCredentials } from "./authExpiredCredentials.ts";
@@ -1039,33 +1040,6 @@ function planLastUsedCommit(
};
}
/**
* Resolve Proxy Pool references on a real connection row at the same boundary
* where credentials become request-ready. The synthetic no-auth fallback above
* already performs this hydration, but a persisted connection (for example the
* OpenCode card's `opencode` row selected through the `opencode-zen` alias)
* bypasses that fallback. Keep inline/legacy entries untouched and only incur a
* registry lookup when at least one by-id reference is present.
*/
async function hydrateAccountProxyReferences(
providerSpecificData: JsonRecord
): Promise<JsonRecord> {
const entries = providerSpecificData.accountProxies;
if (!Array.isArray(entries)) return providerSpecificData;
const containsProxyReference = entries.some((entry) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
const proxyId = (entry as Record<string, unknown>).proxyId;
return typeof proxyId === "string" && proxyId.trim().length > 0;
});
if (!containsProxyReference) return providerSpecificData;
return {
...providerSpecificData,
accountProxies: await resolveAccountProxiesFromRegistry(entries),
};
}
async function materializeConnection(
connection: ProviderConnectionView,
options: CredentialSelectionOptions,
@@ -1074,7 +1048,7 @@ async function materializeConnection(
reactivatedFromInactive?: boolean;
} = {}
) {
const providerSpecificData = await hydrateAccountProxyReferences(connection.providerSpecificData);
const providerSpecificData = await hydrateConnectionProviderSpecificData(connection);
const apiKeyHealth = providerSpecificData.apiKeyHealth as Record<string, KeyHealth> | undefined;
if (apiKeyHealth) syncHealthFromDB(connection.id, apiKeyHealth);
const releaseOAuthSession =

View File

@@ -0,0 +1,95 @@
/**
* #13452: self-heal a `*-compatible-*` connection whose `providerSpecificData`
* never received the write-time `baseUrl` copy (`POST /api/providers`'s
* hydration branch, or the `PUT /api/provider-nodes/{id}` backfill loop are
* the only two places that stamp it). A connection created any other way —
* a direct DB insert, a row that predates the hydration logic, or a race
* with node update/deletion — has no `providerSpecificData.baseUrl` at all
* and, left unfixed, `DefaultExecutor.buildUrl()`/`BaseExecutor.buildUrl()`
* silently defaulted to the REAL OpenAI/Anthropic API, shipping the
* connection's own stored credential there as a Bearer/x-api-key token.
*
* Extracted out of `auth.ts` into its own module (`file-size-baseline.json`
* freezes `auth.ts`'s line count).
*
* @module sse/services/compatibleNodeBaseUrl
*/
import { getCachedProviderNodes } from "@/lib/db/readCache";
import { selectProviderNodeForConnection } from "@/lib/db/providerNodeSelect";
import { isCompatibleProviderConnectionId } from "@/shared/utils/compatibleProviderId";
import { resolveAccountProxiesFromRegistry } from "./noAuthProxyResolution";
type JsonRecord = Record<string, unknown>;
/**
* Composes both boundary-hydration steps a persisted connection's
* `providerSpecificData` needs before it becomes request-ready credentials:
* Proxy Pool by-id references (moved here from `auth.ts` verbatim — was
* `hydrateAccountProxyReferences`), then the `*-compatible-*` baseUrl
* self-heal below. Keep inline/legacy `accountProxies` entries untouched and
* only incur a proxy-registry lookup when at least one by-id reference is
* present.
*/
export async function hydrateConnectionProviderSpecificData(connection: {
provider: string;
providerSpecificData: JsonRecord;
}): Promise<JsonRecord> {
const { providerSpecificData } = connection;
const entries = providerSpecificData.accountProxies;
const containsProxyReference =
Array.isArray(entries) &&
entries.some((entry) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
const proxyId = (entry as Record<string, unknown>).proxyId;
return typeof proxyId === "string" && proxyId.trim().length > 0;
});
const proxyHydrated = containsProxyReference
? { ...providerSpecificData, accountProxies: await resolveAccountProxiesFromRegistry(entries) }
: providerSpecificData;
return hydrateCompatibleNodeBaseUrl(connection.provider, proxyHydrated);
}
/**
* Re-joins `provider_nodes` (via the existing 5s-TTL cache, so this is not
* an extra DB read on the hot path) and stamps the same fields the write-time
* hydration branch stamps. Returns `providerSpecificData` unchanged when the
* connection is not a compatible-node connection, already carries a
* `baseUrl`, or no matching node can be resolved (the executor's fail-loud
* fallback then reports the true "node missing" condition instead of
* silently routing to a public third party).
*/
async function hydrateCompatibleNodeBaseUrl(
provider: string,
providerSpecificData: JsonRecord
): Promise<JsonRecord> {
if (typeof providerSpecificData.baseUrl === "string" && providerSpecificData.baseUrl) {
return providerSpecificData;
}
if (!isCompatibleProviderConnectionId(provider)) return providerSpecificData;
try {
const nodes = (await getCachedProviderNodes()) as JsonRecord[];
const node = selectProviderNodeForConnection(provider, nodes);
if (!node || typeof node.baseUrl !== "string" || !node.baseUrl) return providerSpecificData;
return {
...providerSpecificData,
prefix: providerSpecificData.prefix ?? node.prefix,
apiType: providerSpecificData.apiType ?? node.apiType,
baseUrl: node.baseUrl,
nodeName: providerSpecificData.nodeName ?? node.name,
...(node.chatPath && !providerSpecificData.chatPath ? { chatPath: node.chatPath } : {}),
...(node.modelsPath && !providerSpecificData.modelsPath
? { modelsPath: node.modelsPath }
: {}),
...(node.customHeaders && !providerSpecificData.customHeaders
? { customHeaders: node.customHeaders }
: {}),
};
} catch {
// Best-effort self-heal only — a transient DB/cache read failure must
// not throw here; the executor's own fail-loud fallback is the backstop.
return providerSpecificData;
}
}

View File

@@ -601,9 +601,8 @@ test("DefaultExecutor.execute uses CC-compatible connection defaults to append 1
stream: false,
credentials: {
apiKey: "cc-key",
providerSpecificData: {
ccSessionId: "session-1",
},
// #13452: buildUrl() now requires a hydrated baseUrl.
providerSpecificData: { ccSessionId: "session-1", baseUrl: "https://cc.test/v1" },
},
clientHeaders: {
"x-app": "cli",
@@ -623,6 +622,7 @@ test("DefaultExecutor.execute uses CC-compatible connection defaults to append 1
apiKey: "cc-key",
providerSpecificData: {
ccSessionId: "session-1",
baseUrl: "https://cc.test/v1",
requestDefaults: { context1m: true, redactThinking: true },
},
},
@@ -658,6 +658,7 @@ test("DefaultExecutor.execute uses CC-compatible connection defaults to append 1
apiKey: "cc-key",
providerSpecificData: {
ccSessionId: "session-1",
baseUrl: "https://cc-proxy.example.test/v1",
requestDefaults: { context1m: true },
},
},
@@ -734,9 +735,7 @@ test("DefaultExecutor.execute reports the exact serialized provider request befo
stream: false,
credentials: {
apiKey: "cc-key",
providerSpecificData: {
ccSessionId: "session-1",
},
providerSpecificData: { ccSessionId: "session-1", baseUrl: "https://cc.test/v1" }, // #13452
},
})
);

View File

@@ -0,0 +1,66 @@
import test from "node:test";
import assert from "node:assert/strict";
import { DefaultExecutor } from "../../open-sse/executors/default.ts";
import { BaseExecutor } from "../../open-sse/executors/base.ts";
// Issue #13452 — Bug 2: a provider-node ("openai-compatible-*" /
// "anthropic-compatible-*") connection whose credentials carry no
// providerSpecificData.baseUrl (e.g. a connection created any way other than
// the exact POST /api/providers hydration branch, or the node-update
// backfill loop) used to silently fall back to the literal
// "https://api.openai.com/v1" / "https://api.anthropic.com/v1" instead of
// erroring or re-resolving the node's configured baseUrl. Real traffic meant
// for a local OpenAI-compatible endpoint (Ollama/vLLM/LM Studio) was instead
// sent to the real OpenAI API, carrying whatever string was stored as the
// "API key" as a Bearer token to a public third party.
//
// Fix: buildUrl() now fails loudly (throws) instead of defaulting, and the
// credential-selection read path (src/sse/services/auth.ts) self-heals by
// re-joining provider_nodes before a request ever reaches buildUrl().
test("issue #13452: DefaultExecutor openai-compatible buildUrl must not silently fall back to the real OpenAI API when providerSpecificData.baseUrl is absent", () => {
const nodeId = "openai-compatible-chat-test-node";
const executor = new DefaultExecutor(nodeId);
// Simulates the credentials row a hand-created / non-hydrated connection
// produces: no providerSpecificData.baseUrl at all, even though the node
// itself (in provider_nodes) has baseUrl = "http://localhost:11434/v1".
const credentialsWithoutHydration = { apiKey: "ollama" };
assert.throws(
() => executor.buildUrl("qwen3.6:35b-a3b", false, 0, credentialsWithoutHydration),
/baseUrl/,
"buildUrl() must fail loudly instead of silently defaulting an unhydrated openai-compatible " +
"connection to the real OpenAI API — this WAS the reported bug (#13452)"
);
});
test("issue #13452: BaseExecutor openai-compatible buildUrl must not silently fall back to the real OpenAI API when providerSpecificData.baseUrl is absent", () => {
const executor = new BaseExecutor("openai-compatible-responses-test-node", {});
assert.throws(() => executor.buildUrl("gpt-5.4", true, 0, { apiKey: "local-key" }), /baseUrl/);
});
test("issue #13452: DefaultExecutor anthropic-compatible buildUrl must not silently fall back to the real Anthropic API when providerSpecificData.baseUrl is absent", () => {
const executor = new DefaultExecutor("anthropic-compatible-test-node");
assert.throws(
() => executor.buildUrl("claude-sonnet-4-6", true, 0, { apiKey: "local-key" }),
/baseUrl/
);
});
test("issue #13452 (control): providing providerSpecificData.baseUrl routes correctly (confirms the fallback, not buildUrl() itself, was the defect)", () => {
const nodeId = "openai-compatible-chat-test-node-2";
const executor = new DefaultExecutor(nodeId);
const hydratedCredentials = {
apiKey: "ollama",
providerSpecificData: { baseUrl: "http://localhost:11434/v1" },
};
const url = executor.buildUrl("qwen3.6:35b-a3b", false, 0, hydratedCredentials);
assert.equal(url, "http://localhost:11434/v1/chat/completions");
});