feat(providers): allow local/private provider URLs by default with scoped metadata-safe guard (#5066) (#5107)

This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-26 14:10:09 -03:00
committed by GitHub
parent cc6741dd8c
commit 11cb8f0976
10 changed files with 177 additions and 13 deletions

View File

@@ -244,6 +244,12 @@ ALLOW_API_KEY_REVEAL=false
# Default: false (blocked) | Set true to enable local providers.
# OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS=true
# Allow adding/validating providers on local/private addresses (127.0.0.1, localhost, LAN).
# Used by: src/shared/network/outboundUrlGuard.ts — scopes to the provider validation path and
# still blocks cloud-metadata (169.254.169.254, metadata.google.internal). Default: true
# (OmniRoute is local-first). Set false to enforce strict public-only blocking.
# OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS=false
# Legacy alias toggling the SSRF guard. Used by: src/shared/network/outboundUrlGuard.ts
# When unset, OmniRoute uses the per-feature defaults. Set to "false"/"0" to disable.
# OUTBOUND_SSRF_GUARD_ENABLED=true

View File

@@ -10,6 +10,7 @@ _In development — bullets added per PR; finalized at release._
### ✨ New Features
- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij)
- **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions)
- **kiro**: inline `<thinking>` stream splitter — when `<thinking_mode>enabled</thinking_mode>` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`).
- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<tool▁calls▁begin>…<tool▁calls▁end>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar)

View File

@@ -179,6 +179,7 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari
| `CORS_ORIGIN` | `*` | Next.js middleware | CORS `Access-Control-Allow-Origin` value. Restrict for production. |
| `OUTBOUND_SSRF_GUARD_ENABLED` | `true` | `src/shared/network/outboundUrlGuard.ts` | Block provider calls targeting private/loopback/link-local IP ranges. Disable only in isolated test envs. |
| `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS` | `false` | `src/shared/network/outboundUrlGuard.ts` | Allow provider URLs pointing to private/local networks (localhost, 192.168.x.x, 10.x.x.x, etc.). **REQUIRED for self-hosted providers** (LM Studio, Ollama, vLLM, Llamafile, Triton, SearXNG). When `false`, the dashboard rejects validation of local URLs. |
| `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` | `true` | `src/shared/network/outboundUrlGuard.ts` | Allow adding/validating providers on local/private addresses (127.0.0.1, localhost, LAN, private ranges) — scoped to the provider validation path. **Default `true`** (local-first); set `false` to enforce strict public-only blocking. Cloud-metadata endpoints (169.254.169.254, metadata.google.internal) stay blocked regardless. (#5066) |
### Hardening Checklist

View File

@@ -61,7 +61,7 @@ used when neither a DB override nor an environment variable is present.
| `PII_RESPONSE_SANITIZATION_MODE` | enum | `redact` | Mode for PII response sanitization. Values: `redact`, `warn`, `block`, `off`. |
| `OUTBOUND_SSRF_GUARD_ENABLED` | boolean | `true` | Block outbound requests to private/internal IP ranges. |
### Network (7)
### Network (8)
| Key | Type | Default | Restart | Description |
| ----------------------------------------------- | ------- | ------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -71,6 +71,7 @@ used when neither a DB override nor an environment variable is present.
| `OMNIROUTE_CONTROL_PLANE_PROXY_DIRECT_FALLBACK` | boolean | `false` | | Allow OAuth and provider validation flows to bypass a pinned proxy and connect directly when proxy reachability pre-checks fail. Off by default because this can change egress IP. |
| `MITM_DISABLE_TLS_VERIFY` | boolean | `false` | ✓ | Disable TLS certificate verification for the MITM proxy. **Danger.** |
| `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS` | boolean | `false` | | Allow provider URLs pointing to private/internal networks. |
| `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` | boolean | `true` | | Allow adding/validating providers on local/private addresses (127.0.0.1, localhost, LAN). On by default (local-first); disable for strict public-only blocking. Cloud-metadata stays blocked. |
| `ENABLE_CC_COMPATIBLE_PROVIDER` | boolean | `false` | ✓ | Enable Claude Code compatible provider mode. |
### Policies (3)

View File

@@ -7,7 +7,7 @@ import {
getSafeOutboundFetchErrorStatus,
safeOutboundFetch,
} from "@/shared/network/safeOutboundFetch";
import { getProviderOutboundGuard, isPrivateHost } from "@/shared/network/outboundUrlGuard";
import { getProviderValidationGuard, isPrivateHost } from "@/shared/network/outboundUrlGuard";
import { selectProxyForValidation } from "@omniroute/open-sse/services/proxyAutoSelector.ts";
/**
@@ -24,7 +24,7 @@ export async function fetchWithProxyFallback(
try {
return await safeOutboundFetch(url, {
...presets,
guard: isLocal ? "none" : getProviderOutboundGuard(),
guard: isLocal ? "none" : getProviderValidationGuard(),
...init,
});
} catch (err: unknown) {
@@ -43,7 +43,7 @@ export async function fetchWithProxyFallback(
return safeOutboundFetch(url, {
...presets,
guard: isLocal ? "none" : getProviderOutboundGuard(),
guard: isLocal ? "none" : getProviderValidationGuard(),
...init,
proxyConfig: proxyUrl,
});

View File

@@ -174,6 +174,18 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [
requiresRestart: false,
warningLevel: "caution",
},
{
key: "OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS",
label: "Allow Local Provider URLs",
description:
"Allow adding and validating providers on local/private addresses (127.0.0.1, localhost, LAN, private IP ranges) — needed for local OpenAI-compatible models. Enabled by default (OmniRoute is local-first); turn it OFF to enforce strict public-only blocking if you only use public providers. Cloud-metadata endpoints (e.g. 169.254.169.254) stay blocked either way.",
descriptionI18nKey: "featureFlagOmnirouteAllowLocalProviderUrlsDescription",
category: "network",
defaultValue: "true",
type: "boolean",
requiresRestart: false,
warningLevel: "caution",
},
{
key: "ENABLE_CC_COMPATIBLE_PROVIDER",
label: "CC Compatible Provider",

View File

@@ -4,9 +4,18 @@ import { resolveFeatureFlag } from "@/shared/utils/featureFlags";
const TRUE_ENV_VALUES = new Set(["1", "true", "yes", "on"]);
export const PROVIDER_URL_BLOCKED_MESSAGE = "Blocked private or local provider URL";
export const CLOUD_METADATA_BLOCKED_MESSAGE = "Blocked cloud-metadata endpoint";
export const PRIVATE_PROVIDER_URLS_ENV = "OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS";
// #5066: scoped to provider validation/use. Allows local/private provider endpoints
// (127.0.0.1, localhost, LAN) so local-first OpenAI-compatible providers validate, while
// cloud-metadata endpoints stay blocked. Defaults ON (OmniRoute is local-first); operators
// who only use public providers can disable it to restore strict SSRF blocking.
export const LOCAL_PROVIDER_URLS_ENV = "OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS";
export type OutboundUrlGuardMode = "none" | "public-only";
// "block-metadata": allow private/LAN hosts but still reject cloud-metadata / link-local
// endpoints (the SSRF→IAM-credential pivot). Used by the provider-validation path under the
// local-first default; never relaxes the metadata block.
export type OutboundUrlGuardMode = "none" | "public-only" | "block-metadata";
export type OutboundUrlGuardErrorCode = "OUTBOUND_URL_GUARD_BLOCKED" | "OUTBOUND_URL_INVALID";
type OutboundUrlGuardErrorInit = {
@@ -146,6 +155,26 @@ export function parseAndValidatePublicUrl(input: string | URL) {
return url;
}
/**
* #5066: provider-validation variant. Allows private/LAN hosts (so a local OpenAI-compatible
* provider at 127.0.0.1 validates) but ALWAYS rejects cloud-metadata / link-local endpoints —
* the classic SSRF→IAM-credential pivot, which is never a legitimate provider endpoint.
* Protocol and embedded-credential checks from {@link parseOutboundUrl} still apply.
*/
export function parseAndValidateNonMetadataUrl(input: string | URL) {
const url = parseOutboundUrl(input);
if (isCloudMetadataHost(url.hostname)) {
throw new OutboundUrlGuardError(CLOUD_METADATA_BLOCKED_MESSAGE, {
code: "OUTBOUND_URL_GUARD_BLOCKED",
url: url.toString(),
hostname: url.hostname || null,
});
}
return url;
}
/**
* Webhook variant of {@link parseAndValidatePublicUrl}. Webhooks legitimately point at
* internal services (n8n, Home Assistant, a LAN box) in Docker/self-hosted deployments,
@@ -214,3 +243,34 @@ export function arePrivateProviderUrlsAllowed() {
export function getProviderOutboundGuard(): OutboundUrlGuardMode {
return arePrivateProviderUrlsAllowed() ? "none" : "public-only";
}
/**
* #5066: whether provider endpoints on local/private addresses are permitted. Defaults ON
* (OmniRoute is local-first — local OpenAI-compatible providers should validate out of the
* box). Disable via the `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` flag (DB toggle or env) to
* restore strict public-only SSRF blocking. Cloud-metadata stays blocked regardless.
*/
export function areLocalProviderUrlsAllowed(): boolean {
try {
const dbValue = resolveFeatureFlag(LOCAL_PROVIDER_URLS_ENV);
if (dbValue !== undefined && dbValue !== "") return isTrueValue(dbValue);
} catch {
// DB not initialized yet — fall through to env / default.
}
const envValue = process.env[LOCAL_PROVIDER_URLS_ENV];
if (typeof envValue === "string" && envValue !== "") return isTrueValue(envValue);
// Default ON.
return true;
}
/**
* Guard mode for the provider VALIDATION/use path (not webhooks or remote images). Precedence:
* 1. explicit full opt-in (`arePrivateProviderUrlsAllowed`) → "none" (no checks; power users).
* 2. local-first default (`areLocalProviderUrlsAllowed`) → "block-metadata" (allow LAN, block IMDS).
* 3. otherwise → "public-only" (strict).
*/
export function getProviderValidationGuard(): OutboundUrlGuardMode {
if (arePrivateProviderUrlsAllowed()) return "none";
if (areLocalProviderUrlsAllowed()) return "block-metadata";
return "public-only";
}

View File

@@ -3,6 +3,7 @@ import { FetchTimeoutError, fetchWithTimeout } from "@/shared/utils/fetchTimeout
import {
OutboundUrlGuardError,
type OutboundUrlGuardMode,
parseAndValidateNonMetadataUrl,
parseAndValidatePublicUrl,
parseOutboundUrl,
} from "@/shared/network/outboundUrlGuard";
@@ -157,10 +158,16 @@ function normalizeUrl(input: string | URL) {
}
function applyUrlGuard(targetUrl: URL, guard: SafeOutboundFetchGuard, method: string) {
if (guard !== "public-only") return;
if (guard === "none") return;
try {
parseAndValidatePublicUrl(targetUrl);
// "public-only" rejects every private host; "block-metadata" (#5066) allows private/LAN
// hosts but still rejects cloud-metadata / link-local endpoints.
if (guard === "block-metadata") {
parseAndValidateNonMetadataUrl(targetUrl);
} else {
parseAndValidatePublicUrl(targetUrl);
}
} catch (error) {
if (error instanceof OutboundUrlGuardError) {
throw new SafeOutboundFetchError(error.message, {

View File

@@ -34,13 +34,13 @@ const {
// Test group 1 — Flag definitions registry
// ──────────────────────────────────────────────────────
describe("featureFlagDefinitions", () => {
it("has exactly 37 flag definitions", () => {
assert.strictEqual(FEATURE_FLAG_DEFINITIONS.length, 37);
it("has exactly 38 flag definitions", () => {
assert.strictEqual(FEATURE_FLAG_DEFINITIONS.length, 38);
});
it("has unique keys for all flags", () => {
const keys = FEATURE_FLAG_DEFINITIONS.map((d) => d.key);
assert.strictEqual(new Set(keys).size, 37);
assert.strictEqual(new Set(keys).size, 38);
});
it("has valid categories for all flags", () => {
@@ -295,9 +295,9 @@ describe("resolveFeatureFlag", () => {
});
describe("resolveAllFeatureFlags", () => {
it("returns all 37 flags", () => {
it("returns all 38 flags", () => {
const all = resolveAllFeatureFlags();
assert.strictEqual(all.length, 37);
assert.strictEqual(all.length, 38);
});
it("marks DB-overridden flags with source 'db'", () => {

View File

@@ -7,6 +7,7 @@ import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-providers-validate-route-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const originalAllowPrivateProviderUrls = process.env.OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS;
const originalAllowLocalProviderUrls = process.env.OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS;
// Load modules at top level
const core = await import("../../src/lib/db/core.ts");
@@ -27,6 +28,11 @@ test.after(() => {
} else {
process.env.OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS = originalAllowPrivateProviderUrls;
}
if (originalAllowLocalProviderUrls === undefined) {
delete process.env.OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS;
} else {
process.env.OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS = originalAllowLocalProviderUrls;
}
});
test("providers validate route returns 400 for invalid JSON", async () => {
@@ -106,9 +112,12 @@ test("providers validate route forwards baseUrl to built-in specialty validators
}
});
test("providers validate route blocks private baseUrl values by default", async () => {
test("providers validate route blocks private baseUrl values when local provider URLs are disabled", async () => {
await resetStorage();
delete process.env.OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS;
// #5066: local provider URLs are allowed by default; this test exercises the strict
// public-only path by explicitly disabling the local-first allowance.
process.env.OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS = "false";
let called = false;
const originalFetch = globalThis.fetch;
@@ -148,6 +157,73 @@ test("providers validate route blocks private baseUrl values by default", async
reason: "Blocked private or local provider URL",
baseUrl: "http://127.0.0.1:8080",
});
} finally {
globalThis.fetch = originalFetch;
delete process.env.OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS;
}
});
test("providers validate route allows a local baseUrl by default (#5066 local-first)", async () => {
await resetStorage();
delete process.env.OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS;
delete process.env.OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS; // default ON
const originalFetch = globalThis.fetch;
globalThis.fetch = async (url, init = {}) => {
assert.equal(String(url), "http://127.0.0.1:3264/api/v1/chat/completions");
return new Response(JSON.stringify({ error: "bad request" }), { status: 400 });
};
try {
const request = new Request("http://localhost/api/providers/validate", {
method: "POST",
body: JSON.stringify({
provider: "heroku",
apiKey: "local-key",
baseUrl: "http://127.0.0.1:3264/api",
}),
});
const response = await validateRoute.POST(request);
const body = (await response.json()) as { valid?: boolean };
// A reachable local endpoint must NOT be SSRF-blocked — it validates (the 400 from the
// local server is a normal validation outcome, not an outbound-guard 503).
assert.equal(response.status, 200);
assert.equal(body.valid, true);
} finally {
globalThis.fetch = originalFetch;
}
});
test("providers validate route still blocks cloud-metadata even with local URLs allowed (#5066)", async () => {
await resetStorage();
delete process.env.OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS;
delete process.env.OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS; // default ON
let called = false;
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => {
called = true;
return Response.json({ ok: true });
};
try {
const request = new Request("http://localhost/api/providers/validate", {
method: "POST",
body: JSON.stringify({
provider: "heroku",
apiKey: "heroku-key",
baseUrl: "http://169.254.169.254/latest/meta-data",
}),
});
const response = await validateRoute.POST(request);
// The IMDS / cloud-metadata pivot is never a valid provider endpoint — blocked even
// when local/private provider URLs are allowed.
assert.equal(response.status, 503);
assert.equal(called, false);
} finally {
globalThis.fetch = originalFetch;
}