mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-21 22:52:19 +03:00
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:
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
38
tests/unit/base-executor-ssrf-guard.test.ts
Normal file
38
tests/unit/base-executor-ssrf-guard.test.ts
Normal 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")(""));
|
||||
});
|
||||
Reference in New Issue
Block a user