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.
This commit is contained in:
Xiangzhe
2026-08-21 14:01:27 -03:00
parent ef78596876
commit b42e57f97d
4 changed files with 73 additions and 0 deletions

View File

@@ -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<typeof setTimeout> | null = null;
if (timeoutController) {

View File

@@ -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,

View File

@@ -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,

View File

@@ -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")(""));
});