From 11cb8f097653d2a7be4f2833240c02f564ba7d7b Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 26 Jun 2026 14:10:09 -0300 Subject: [PATCH] feat(providers): allow local/private provider URLs by default with scoped metadata-safe guard (#5066) (#5107) --- .env.example | 6 ++ CHANGELOG.md | 1 + docs/reference/ENVIRONMENT.md | 1 + docs/reference/FEATURE_FLAGS.md | 3 +- src/lib/providers/validation/transport.ts | 6 +- .../constants/featureFlagDefinitions.ts | 12 +++ src/shared/network/outboundUrlGuard.ts | 62 ++++++++++++++- src/shared/network/safeOutboundFetch.ts | 11 ++- tests/unit/feature-flags-settings.test.ts | 10 +-- tests/unit/providers-validate-route.test.ts | 78 ++++++++++++++++++- 10 files changed, 177 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index 3099694ed8..8f0fccb559 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 580488b69d..75326bf72a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `` stream splitter — when `enabled` 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) diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 5fef70fbac..c48fb8c520 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -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 diff --git a/docs/reference/FEATURE_FLAGS.md b/docs/reference/FEATURE_FLAGS.md index 5da6146a0a..bb6c0b2fe1 100644 --- a/docs/reference/FEATURE_FLAGS.md +++ b/docs/reference/FEATURE_FLAGS.md @@ -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) diff --git a/src/lib/providers/validation/transport.ts b/src/lib/providers/validation/transport.ts index 4909f589fb..6321ab4629 100644 --- a/src/lib/providers/validation/transport.ts +++ b/src/lib/providers/validation/transport.ts @@ -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, }); diff --git a/src/shared/constants/featureFlagDefinitions.ts b/src/shared/constants/featureFlagDefinitions.ts index aafd9aa8ef..1c4d19d628 100644 --- a/src/shared/constants/featureFlagDefinitions.ts +++ b/src/shared/constants/featureFlagDefinitions.ts @@ -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", diff --git a/src/shared/network/outboundUrlGuard.ts b/src/shared/network/outboundUrlGuard.ts index 41c108c5e9..1f51145c9d 100644 --- a/src/shared/network/outboundUrlGuard.ts +++ b/src/shared/network/outboundUrlGuard.ts @@ -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"; +} diff --git a/src/shared/network/safeOutboundFetch.ts b/src/shared/network/safeOutboundFetch.ts index 7638ee44c1..90cc54e440 100644 --- a/src/shared/network/safeOutboundFetch.ts +++ b/src/shared/network/safeOutboundFetch.ts @@ -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, { diff --git a/tests/unit/feature-flags-settings.test.ts b/tests/unit/feature-flags-settings.test.ts index ab7fa4afdc..36308bfc86 100644 --- a/tests/unit/feature-flags-settings.test.ts +++ b/tests/unit/feature-flags-settings.test.ts @@ -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'", () => { diff --git a/tests/unit/providers-validate-route.test.ts b/tests/unit/providers-validate-route.test.ts index ba41279001..d607acb014 100644 --- a/tests/unit/providers-validate-route.test.ts +++ b/tests/unit/providers-validate-route.test.ts @@ -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; }