diff --git a/CHANGELOG.md b/CHANGELOG.md index a3f9a7f3f0..a69e5fef11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral - **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) - **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. - **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. +- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur) - **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one). - **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`. - **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0` … `ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127) diff --git a/src/lib/providers/validation/headers.ts b/src/lib/providers/validation/headers.ts index 24116c36bd..aabc65339e 100644 --- a/src/lib/providers/validation/headers.ts +++ b/src/lib/providers/validation/headers.ts @@ -2,6 +2,7 @@ // from validation.ts (god-file decomposition). Pure header construction except directHttpsRequest, // which delegates to safeOutboundFetch with bypassProxyPatch. Behavior is byte-identical. import { safeOutboundFetch } from "@/shared/network/safeOutboundFetch"; +import { getProviderValidationGuard } from "@/shared/network/outboundUrlGuard"; // Standardized desktop Chrome UA for web-cookie/no-auth session probes (minimizes anti-bot detection). export const STANDARD_USER_AGENT = @@ -40,6 +41,17 @@ export function withCustomUserAgent(init: RequestInit, providerSpecificData: any * Direct HTTPS request utility that bypasses the global patched fetch. * Used for provider validation where the patched fetch has compatibility issues. * Uses safeOutboundFetch with bypassProxyPatch to use native Node.js fetch directly. + * + * SSRF hardening: provider validation hits a caller-controllable `baseUrl` + * (e.g. the web-cookie `${baseUrl}/models` probe), so it MUST NOT be an open + * relay to cloud-metadata endpoints. We apply `getProviderValidationGuard()` + * (local-first default = "block-metadata": allow LAN/localhost providers but + * reject 169.254.169.254 / link-local IMDS; power users can opt fully out via + * `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`). `applyUrlGuard` only checks the + * INITIAL url, so `allowRedirect: false` is required too — otherwise a provider + * could 3xx-redirect the probe to metadata past the guard. Validation targets + * are concrete API endpoints (/models, /chat/completions, /v1/messages) that + * return direct JSON, so blocking redirects loses no legitimate provider. */ export function directHttpsRequest( url: string, @@ -52,8 +64,8 @@ export function directHttpsRequest( body: options.body, timeoutMs, bypassProxyPatch: true, - allowRedirect: true, - guard: "none", + allowRedirect: false, + guard: getProviderValidationGuard(), retry: false, }).then(async (response) => ({ status: response.status, diff --git a/tests/unit/provider-validation-ssrf-guard.test.ts b/tests/unit/provider-validation-ssrf-guard.test.ts new file mode 100644 index 0000000000..555693d0de --- /dev/null +++ b/tests/unit/provider-validation-ssrf-guard.test.ts @@ -0,0 +1,54 @@ +/** + * QA P0 (security) — provider-validation SSRF guard. + * + * `directHttpsRequest` (used by web-cookie / NVIDIA / Z.AI validation, all of + * which accept a caller-controllable baseUrl) previously ran with + * `guard: "none"` + `allowRedirect: true`, i.e. an open relay to cloud-metadata + * endpoints. It now runs with `getProviderValidationGuard()` (default + * "block-metadata") + `allowRedirect: false`. These tests assert the guard + * rejects IMDS / link-local targets BEFORE any network call, while ordinary + * public hosts still pass the guard. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { directHttpsRequest } = await import("../../src/lib/providers/validation/headers.ts"); + +const METADATA_TARGETS = [ + "http://169.254.169.254/latest/meta-data/", // AWS/GCP IMDS + "http://[fd00:ec2::254]/latest/meta-data/", // AWS IMDSv6 + "http://metadata.google.internal/computeMetadata/v1/", // GCP metadata host +]; + +for (const url of METADATA_TARGETS) { + test(`SSRF: directHttpsRequest blocks cloud-metadata target ${url}`, async () => { + await assert.rejects( + () => directHttpsRequest(url, { method: "GET" }, 2000), + (err: unknown) => { + const msg = String((err as Error)?.message ?? err); + // Must be a guard rejection, not a network timeout/connect error — i.e. + // the request was refused before any socket was opened. + assert.match(msg, /guard|metadata|blocked|not allowed|URL/i, `expected guard block, got: ${msg}`); + return true; + } + ); + }); +} + +test("SSRF: a normal public provider host is NOT blocked by the guard", async () => { + // block-metadata permits public + LAN hosts; only IMDS/link-local are refused. + // We use an unroutable TEST-NET-1 address (RFC 5737) so no real traffic leaves, + // and assert the failure is a network error (guard passed), never a guard block. + await assert.rejects( + () => directHttpsRequest("http://192.0.2.1:9/models", { method: "GET" }, 1500), + (err: unknown) => { + const msg = String((err as Error)?.message ?? err); + assert.doesNotMatch( + msg, + /url_guard_blocked|guard blocked|metadata/i, + `public host must pass the guard, got a guard block: ${msg}` + ); + return true; + } + ); +});