From b42e57f97d3fbd74aa4aea1b2c5a072ed233633b Mon Sep 17 00:00:00 2001 From: Xiangzhe Date: Fri, 21 Aug 2026 14:01:27 -0300 Subject: [PATCH] fix(security): SSRF guard on the executor dispatch path (provider baseUrl) A persisted, caller-supplied providerSpecificData.baseUrl reached fetch() on the runtime dispatch path with no SSRF guard, so a manage-scope actor (or an anonymous one on a keyless install) could point a provider at loopback / internal / cloud-metadata hosts and reach the instance metadata service. BaseExecutor now mirrors the provider validation guard before every upstream fetch (fetchWithStartTimeout covers retries/fallback URLs; countTokens too), with the same call added to the glm and nlpcloud executors' own fetch paths. Local / self-hosted providers stay exempt; default block-metadata mode stops the cloud-metadata IMDS pivot, public-only mode also blocks private targets. Reported by @rafaelfiguereod-stack via GHSA-4f49-hj64-448x. --- open-sse/executors/base.ts | 33 ++++++++++++++++++ open-sse/executors/glm.ts | 1 + open-sse/executors/nlpcloud.ts | 1 + tests/unit/base-executor-ssrf-guard.test.ts | 38 +++++++++++++++++++++ 4 files changed, 73 insertions(+) create mode 100644 tests/unit/base-executor-ssrf-guard.test.ts diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 877431b68e..66e10b67e9 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -104,6 +104,12 @@ import { import { applyPeerTraceHeader } from "@/shared/resilience/peerRouting"; import { applyClineProtocolHeaders } from "@/shared/utils/clineAuth"; import { isProbeContext } from "@/shared/utils/probeOrigin"; +import { + parseAndValidatePublicUrl, + parseAndValidateNonMetadataUrl, +} from "@/shared/network/outboundUrlGuard"; +import { getProviderValidationGuard } from "@/shared/network/outboundUrlGuardPolicy"; +import { isLocalProvider, isSelfHostedChatProvider } from "@/shared/constants/providers"; // Header helpers extracted to a pure leaf; re-exported for external importers // (executors + tests) that import them from "./base.ts". export { @@ -397,6 +403,29 @@ export class BaseExecutor { return fallback || this.config.baseUrl || ""; } + /** + * SSRF guard for the runtime dispatch path (GHSA-4f49-hj64-448x). A persisted, + * caller-supplied `providerSpecificData.baseUrl` reaches the fetch() calls + * below, so a `manage`-scope actor (or, on a keyless install, an anonymous + * one) could point a provider at loopback / internal / cloud-metadata hosts + * and exfiltrate the stored upstream key. Mirror the provider VALIDATION + * guard so runtime dispatch makes the same decision the validation layer + * already makes: local / self-hosted providers are exempt (they legitimately + * use private URLs, and the OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS opt-in still + * applies through the guard), and for everything else `public-only` mode + * blocks private + metadata while the default `block-metadata` mode blocks the + * cloud-metadata IMDS pivot. Throws on a blocked URL. + */ + protected assertOutboundUrlAllowed(url: string): void { + if (!url) return; + if (isLocalProvider(this.provider) || isSelfHostedChatProvider(this.provider)) return; + if (getProviderValidationGuard() === "public-only") { + parseAndValidatePublicUrl(url); + return; + } + parseAndValidateNonMetadataUrl(url); + } + /** * Alternate protocol selected on this connection, if the provider declares one * that matches. Centralizes the registry lookup so every call-site resolves the @@ -615,6 +644,7 @@ export class BaseExecutor { async countTokens({ model, body, credentials, signal, log }: CountTokensInput) { const url = this.buildCountTokensUrl(model, credentials); if (!url) return null; + this.assertOutboundUrlAllowed(url); // GHSA-4f49 const headers = this.buildHeaders(credentials, false); const requestBody = @@ -869,6 +899,9 @@ export class BaseExecutor { // Timeout only covers response start; stream stalls are handled downstream. const fetchStartTimeoutMs = this.getTimeoutMs(); const fetchWithStartTimeout = async (requestUrl: string, requestOptions: RequestInit) => { + // GHSA-4f49: guard here (not only next to the first buildUrl) so retries + // and fallback URLs are validated too, before any bytes leave the host. + this.assertOutboundUrlAllowed(requestUrl); const timeoutController = fetchStartTimeoutMs > 0 ? new AbortController() : null; let timeoutId: ReturnType | null = null; if (timeoutController) { diff --git a/open-sse/executors/glm.ts b/open-sse/executors/glm.ts index 6318aaab2e..a222571022 100644 --- a/open-sse/executors/glm.ts +++ b/open-sse/executors/glm.ts @@ -430,6 +430,7 @@ export class GlmExecutor extends DefaultExecutor { let response: Response; try { + this.assertOutboundUrlAllowed(url); // GHSA-4f49: glm has its own fetch path response = await fetch(url, { method: "POST", headers, diff --git a/open-sse/executors/nlpcloud.ts b/open-sse/executors/nlpcloud.ts index d413b5a683..e212a38efe 100644 --- a/open-sse/executors/nlpcloud.ts +++ b/open-sse/executors/nlpcloud.ts @@ -471,6 +471,7 @@ export class NlpCloudExecutor extends BaseExecutor { } try { + this.assertOutboundUrlAllowed(url); // GHSA-4f49: nlpcloud has its own fetch path const response = await fetch(url, { method: "POST", headers, diff --git a/tests/unit/base-executor-ssrf-guard.test.ts b/tests/unit/base-executor-ssrf-guard.test.ts new file mode 100644 index 0000000000..054bd883b6 --- /dev/null +++ b/tests/unit/base-executor-ssrf-guard.test.ts @@ -0,0 +1,38 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { DefaultExecutor } from "../../open-sse/executors/default.ts"; + +// GHSA-4f49-hj64-448x — a persisted, caller-supplied providerSpecificData.baseUrl +// reaches fetch() on the runtime dispatch path with no SSRF guard. BaseExecutor +// now mirrors the provider VALIDATION guard before every upstream fetch. In the +// shipped default (block-metadata) mode the cloud-metadata IMDS pivot is blocked +// for non-local providers, public upstreams pass, and local / self-hosted +// providers (vLLM, LM Studio, Ollama, …) stay exempt so loopback/LAN keeps working. + +function guardOf(provider: string) { + const exec = new DefaultExecutor(provider) as unknown as { + assertOutboundUrlAllowed(url: string): void; + }; + return (url: string) => exec.assertOutboundUrlAllowed(url); +} + +test("BaseExecutor blocks cloud-metadata for a non-local provider (GHSA-4f49-hj64-448x)", () => { + const guard = guardOf("openai"); + assert.throws(() => guard("http://169.254.169.254/latest/meta-data/iam/security-credentials/")); + // IPv4-mapped IPv6 spelling of the same address (folded out by #10843). + assert.throws(() => guard("http://[::ffff:169.254.169.254]/latest/meta-data/")); +}); + +test("BaseExecutor allows a public upstream URL for a non-local provider", () => { + const guard = guardOf("openai"); + assert.doesNotThrow(() => guard("https://api.openai.com/v1/chat/completions")); +}); + +test("BaseExecutor exempts local / self-hosted providers from the outbound guard", () => { + assert.doesNotThrow(() => guardOf("ollama-local")("http://127.0.0.1:11434/v1/chat/completions")); + assert.doesNotThrow(() => guardOf("lm-studio")("http://192.168.1.50:1234/v1/chat/completions")); +}); + +test("BaseExecutor guard is a no-op for an empty URL", () => { + assert.doesNotThrow(() => guardOf("openai")("")); +});