diff --git a/CORS.md b/CORS.md new file mode 100644 index 0000000..6ea83fa --- /dev/null +++ b/CORS.md @@ -0,0 +1,161 @@ +> 🌍 [View in other languages](Languages) + + +# CORS Configuration & Security + +OmniRoute controls which **browser origins** may read cross-origin responses +from a single, centralized allowlist. The model is **fail-closed by default**: +no origin is allowed until you opt one in. This page documents how the allowlist +resolves, what `CORS_ALLOW_ALL=true` actually exposes (and, importantly, what it +does **not**), how to configure dev vs production safely, and the runtime warning +the dashboard shows when a wildcard is live. + +**Source of truth:** `src/server/cors/origins.ts` (`resolveAllowedOrigin`, +`applyCorsHeaders`, `getCorsStatus`). The allowlist is applied once, in the +middleware (`src/server/authz/pipeline.ts`) — per-route handlers do not set +`Access-Control-Allow-Origin` themselves. + +## How an origin is resolved + +For each request the middleware computes the `Access-Control-Allow-Origin` value +in this order: + +1. **`CORS_ALLOW_ALL=true`** (or the legacy `CORS_ORIGIN=*`) → echo the caller's + `Origin` back (or `*` when there is no `Origin` header), with `Vary: Origin` + so caches stay correct. +2. Otherwise, the request `Origin` is normalized (lower-cased, trailing slash + stripped) and matched against the **merged allowlist**: + - env **`CORS_ALLOWED_ORIGINS`** — comma-separated list, and + - the runtime **`corsOrigins`** setting (Dashboard → Security → _CORS Allowed + Origins_), injected via `setRuntimeAllowedOrigins()` from + `src/lib/config/runtimeSettings.ts`. +3. No match → **no `Access-Control-Allow-Origin` header is emitted**. The browser + blocks the cross-origin read. This is the intended fail-closed default. + +| Env var | Meaning | +| ---------------------- | ------------------------------------------------------------------------------------ | +| `CORS_ALLOWED_ORIGINS` | CSV of exact origins to allow (recommended). | +| `CORS_ALLOW_ALL` | `true`/`1` → echo any origin (wildcard). Dev only. | +| `CORS_ORIGIN` | Legacy. `*` behaves like `CORS_ALLOW_ALL`; a single value is added to the allowlist. | + +## Threat model — what `CORS_ALLOW_ALL=true` really exposes + +The generic OWASP warning ("wildcard CORS = any site can call your API") is worth +taking seriously, but OmniRoute's exposure is **narrower than the generic case**, +because of one concrete implementation fact: + +> **The central `applyCorsHeaders()` never emits +> `Access-Control-Allow-Credentials`.** A browser will not expose a _credentialed_ +> (cookie-bearing) cross-origin response unless the server sends +> `Access-Control-Allow-Credentials: true`. OmniRoute's shared CORS path never +> does. + +What that means per surface, even with `CORS_ALLOW_ALL=true`: + +| Surface | Auth mechanism | Effect of wildcard CORS | +| ----------------------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Dashboard / MANAGEMENT `/api/*` | Cookie session | Origin is echoed, but **without `Allow-Credentials`** the browser **blocks** the credentialed read. A malicious cross-origin site **cannot read** your authenticated dashboard responses, and the session cookie is not exposed. | +| Client API `/v1/*`, `/v1beta/*` | Bearer / `x-api-key` header | Already permissive **by design** (`relaxForTokenAuth`): browsers never auto-attach `Authorization`/`x-api-key`, so an attacker's page cannot supply your key. `CORS_ALLOW_ALL` does not widen this. | +| Public read-only (`/api/health`, …) | None | Non-sensitive; wildcard is harmless. | + +So the **residual** exposure of `CORS_ALLOW_ALL=true` is limited to: (a) +non-credentialed cross-origin **reads** of already-unauthenticated data, and (b) +letting CORS **preflight pass** on management routes — which still require auth +that a cross-origin page cannot provide. It is **not** a session-hijack or +credential-theft vector on the shared CORS path. + +### One genuine exception — `/api/v1/agents/` + +The Cloud-Agent routes (`/api/v1/agents/{health,credentials,tasks,tasks/[id]}`) set +their **own** CORS headers +(`src/lib/cloudAgent/api.ts`, `getCloudAgentCorsHeaders`) and **do** emit +`Access-Control-Allow-Origin: |*` together with +`Access-Control-Allow-Credentials: true`. This is the single surface where +origin-echo and credentials coexist, and it is **independent of +`CORS_ALLOW_ALL`**. These routes are management-authenticated +(`requireManagementAuth`); operators who expose the dashboard off-host should be +aware that this is the one place a cross-origin credentialed read is permitted by +the response headers. Tightening it to an explicit allowlist is tracked +separately from this CORS guidance. + +## Production checklist + +- **Never set `CORS_ALLOW_ALL=true` in production.** Leave it unset. +- Set an **explicit** origin list — either the env var or the Security-tab field: + + ```bash + CORS_ALLOWED_ORIGINS="https://app.example.com, https://admin.example.com" + ``` + +- If OmniRoute runs behind a reverse proxy / tunnel (nginx, Caddy, Cloudflare + Tunnel, Tailscale), CORS is **not** your only control — the loopback route + guard still protects spawn-capable routes (see + [ROUTE_GUARD_TIERS](./ROUTE_GUARD_TIERS.md)). Do not forge + `X-Forwarded-For: 127.0.0.1` to "fix" a 403; that re-opens the RCE class the + route guard closes. +- Confirm the runtime state: the dashboard shows a **persistent amber banner** + under Dashboard → Security → Authorization Inventory whenever + `CORS_ALLOW_ALL=true` is live, and `/api/settings/authz-inventory` returns a + `cors: { allowAll, allowedOrigins }` envelope monitoring tools can poll. + +## Development convenience — allow specific local origins + +You rarely need the wildcard even in dev. Allow just the dev servers you use: + +```bash +# Vite (5173) + Next.js (3000) dev servers calling a local OmniRoute +CORS_ALLOWED_ORIGINS="http://localhost:5173, http://localhost:3000" +``` + +Origins are matched case-insensitively with the trailing slash ignored, so +`http://localhost:3000` and `http://localhost:3000/` are equivalent. The same CSV +can be set at runtime in **Dashboard → Security → CORS Allowed Origins** without a +restart. + +## API keys vs cookie sessions + +- **Bearer / `x-api-key` (the `/v1/*` inference surface):** browsers never attach + these automatically. CORS is not a meaningful barrier here — the API key is the + barrier — which is why that surface is intentionally permissive so browser and + Electron clients can read responses they are already entitled to. +- **Cookie session (the dashboard):** protected by the fail-closed default **and** + by the absence of `Access-Control-Allow-Credentials` on the shared path. Keep + management/dashboard origins out of any permissive config; they must stay exactly + fail-closed. + +## Example: reverse proxy in front of OmniRoute + +CORS is enforced by OmniRoute itself, so the proxy generally should **not** add or +rewrite `Access-Control-*` headers (double headers break browsers). Terminate TLS +and forward — let OmniRoute answer preflight: + +```nginx +# nginx — forward to OmniRoute; do NOT inject Access-Control-* here +location / { + proxy_pass http://127.0.0.1:20128; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + # Do NOT set X-Forwarded-For to 127.0.0.1 — it defeats the loopback route guard. +} +``` + +Set the allowed browser origins in OmniRoute (`CORS_ALLOWED_ORIGINS` or the +Security tab), not in the proxy. + +## Source files + +| Concern | File | +| ----------------------------------------------- | -------------------------------------------------------------------- | +| Allowlist resolution + `getCorsStatus()` | `src/server/cors/origins.ts` | +| Middleware application (single source of truth) | `src/server/authz/pipeline.ts` | +| Settings → runtime origin injection | `src/lib/config/runtimeSettings.ts` | +| Runtime status for the dashboard | `src/app/api/settings/authz-inventory/route.ts` | +| Dashboard warning banner | `src/app/(dashboard)/dashboard/settings/components/AuthzSection.tsx` | +| CORS Allowed Origins field | `src/app/(dashboard)/dashboard/settings/components/SecurityTab.tsx` | +| Cloud-Agent per-route CORS (the exception) | `src/lib/cloudAgent/api.ts` | + +## See also + +- [Route Guard Tiers](./ROUTE_GUARD_TIERS.md) — loopback enforcement for + spawn-capable routes (a separate, complementary control). +- [Authorization Guide](../architecture/AUTHZ_GUIDE.md) — the full auth pipeline. diff --git a/Provider-Plugin-Manifest.md b/Provider-Plugin-Manifest.md new file mode 100644 index 0000000..1a3a5b9 --- /dev/null +++ b/Provider-Plugin-Manifest.md @@ -0,0 +1,78 @@ +> 🌍 [View in other languages](Languages) + + +# Provider Plugin Manifest + +`open-sse/config/providerPluginManifest.ts` defines the JSON-safe provider +plugin contract. `open-sse/config/providerPluginManifestRegistry.ts` binds that +contract to the current provider registry for sidecars such as Bifrost, +CLIProxyAPI, or a future Go/Rust router. The TypeScript registry remains the +source of truth, but sidecars can consume the manifest without importing +executor code, OAuth defaults, headers, or process environment state. + +The same manifest is available over HTTP at +`GET /api/v1/provider-plugin-manifest` for sidecars that run out-of-process. + +OmniRoute advertises that URL to Bifrost and CLIProxyAPI via the +`X-OmniRoute-Provider-Manifest-Url` request header. Set +`OMNIROUTE_PROVIDER_MANIFEST_URL` when the sidecar needs a public or container +network URL instead of the local request origin. + +## Goal + +Move provider metadata toward a plugin contract so the hot request path can +eventually be owned by a lower-latency sidecar while OmniRoute keeps the +TypeScript route as the policy gate and fallback. The manifest is additive: it +does not change request routing by itself. + +## Contract + +The manifest contains: + +- provider id and alias +- upstream format and executor name +- auth type, auth header, and optional auth prefix +- static endpoint metadata +- sidecar eligibility and explicit reasons when a provider should stay on TS +- JSON-safe model metadata such as context length, vision/reasoning flags, and + unsupported params +- capability tags including `apikey`, `oauth`, `custom-executor`, + `passthrough-models`, `responses`, and `sidecar-candidate` + +The manifest intentionally excludes: + +- OAuth client secrets and default secret values +- runtime environment resolution +- request headers and public credential helpers +- dynamic URL builders +- executor functions +- session pool internals + +## Sidecar Use + +Sidecars should treat `sidecar.eligible` as a conservative candidate signal, not +as an unconditional routing decision. The first import target should be +API-key, static-endpoint providers using the default executor. Providers with +custom web executors, OAuth/session flows, dynamic URL builders, or pool config +stay on the TypeScript fallback path until a sidecar implements equivalent +behavior and telemetry proves parity. + +Suggested migration phases: + +1. Generate and validate the provider plugin manifest from the TS registry. +2. Teach Bifrost or CLIProxyAPI to import the manifest for API-key/static + providers. +3. Route eligible providers through the sidecar behind `OMNIROUTE_RELAY_BACKEND` + while keeping TS fallback enabled. +4. Promote providers only when success rate, p99 latency, streaming behavior, + and unsupported-param handling match the TS path. +5. Add sidecar-native plugins for custom executors one provider family at a + time. + +## Why Not Embed Providers Directly In Next + +The Next frontend should not own provider execution. It should call the API +boundary. The backend can then decide whether to use the TypeScript executor, +Bifrost, CLIProxyAPI, or a future native sidecar. This keeps request signing, +allowlist checks, DB policy, and fallback behavior centralized before any +sidecar handoff.