Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions docs/reference/ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion docs/reference/FEATURE_FLAGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| ----------------------------------------------- | ------- | ------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
Expand All @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions src/lib/providers/validation/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand All @@ -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) {
Expand All @@ -43,7 +43,7 @@ export async function fetchWithProxyFallback(

return safeOutboundFetch(url, {
...presets,
guard: isLocal ? "none" : getProviderOutboundGuard(),
guard: isLocal ? "none" : getProviderValidationGuard(),
...init,
proxyConfig: proxyUrl,
});
Expand Down
12 changes: 12 additions & 0 deletions src/shared/constants/featureFlagDefinitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
64 changes: 62 additions & 2 deletions src/shared/network/outboundUrlGuard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

export type OutboundUrlGuardMode = "none" | "public-only";
// #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";

// "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 = {
Expand Down Expand Up @@ -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;
}
Comment on lines +164 to +176

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-critical critical

An attacker can bypass the cloud-metadata block by using IPv4-mapped IPv6 addresses (e.g., [::ffff:169.254.169.254]). Since isCloudMetadataHost only performs simple string checks against CLOUD_METADATA_HOSTNAMES and checks if the host starts with "169.254.", it will return false for IPv4-mapped IPv6 addresses. However, modern network stacks will resolve and route these addresses to the IPv4 link-local address, allowing the attacker to access the cloud metadata service (IMDS) and potentially retrieve sensitive IAM credentials.\n\nTo prevent this, we should normalize the hostname by stripping the ::ffff: prefix if the remaining part is a valid IPv4 address before performing the metadata check.

export function parseAndValidateNonMetadataUrl(input: string | URL) {\n  const url = parseOutboundUrl(input);\n  let host = normalizeHost(url.hostname);\n\n  if (host.startsWith("::ffff:")) {\n    const ipv4 = host.slice(7);\n    if (isIP(ipv4) === 4) {\n      host = ipv4;\n    }\n  }\n\n  if (isCloudMetadataHost(host)) {\n    throw new OutboundUrlGuardError(CLOUD_METADATA_BLOCKED_MESSAGE, {\n      code: "OUTBOUND_URL_GUARD_BLOCKED",\n      url: url.toString(),\n      hostname: url.hostname || null,\n    });\n  }\n\n  return url;\n}


/**
* Webhook variant of {@link parseAndValidatePublicUrl}. Webhooks legitimately point at
* internal services (n8n, Home Assistant, a LAN box) in Docker/self-hosted deployments,
Expand Down Expand Up @@ -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";
}
11 changes: 9 additions & 2 deletions src/shared/network/safeOutboundFetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { FetchTimeoutError, fetchWithTimeout } from "@/shared/utils/fetchTimeout
import {
OutboundUrlGuardError,
type OutboundUrlGuardMode,
parseAndValidateNonMetadataUrl,
parseAndValidatePublicUrl,
parseOutboundUrl,
} from "@/shared/network/outboundUrlGuard";
Expand Down Expand Up @@ -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, {
Expand Down
10 changes: 5 additions & 5 deletions tests/unit/feature-flags-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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'", () => {
Expand Down
78 changes: 77 additions & 1 deletion tests/unit/providers-validate-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Comment on lines +211 to 227

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current test only verifies that the standard IPv4 cloud-metadata address 169.254.169.254 is blocked. To ensure robust protection against SSRF bypasses, we should expand the test coverage to also verify that IPv4-mapped IPv6 addresses (e.g., [::ffff:169.254.169.254]) and native IPv6 IMDS addresses (e.g., [fd00:ec2::254]) are strictly blocked.

  const metadataUrls = [\n    "http://169.254.169.254/latest/meta-data",\n    "http://[::ffff:169.254.169.254]/latest/meta-data",\n    "http://[fd00:ec2::254]/latest/meta-data"\n  ];\n\n  try {\n    for (const baseUrl of metadataUrls) {\n      const request = new Request("http://localhost/api/providers/validate", {\n        method: "POST",\n        body: JSON.stringify({\n          provider: "heroku",\n          apiKey: "heroku-key",\n          baseUrl,\n        }),\n      });\n\n      const response = await validateRoute.POST(request);\n\n      // The IMDS / cloud-metadata pivot is never a valid provider endpoint — blocked even\n      // when local/private provider URLs are allowed.\n      assert.equal(response.status, 503, 'Should block metadata URL: ' + baseUrl);\n      assert.equal(called, false);\n    }\n  } finally {

globalThis.fetch = originalFetch;
}
Expand Down
Loading