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:
CORS_ALLOW_ALL=true(or the legacyCORS_ORIGIN=*) → echo the caller'sOriginback (or*when there is noOriginheader), withVary: Originso caches stay correct.- Otherwise, the request
Originis normalized (lower-cased, trailing slash stripped) and matched against the merged allowlist:- env
CORS_ALLOWED_ORIGINS— comma-separated list, and - the runtime
corsOriginssetting (Dashboard → Security → CORS Allowed Origins), injected viasetRuntimeAllowedOrigins()fromsrc/lib/config/runtimeSettings.ts.
- env
- No match → no
Access-Control-Allow-Originheader 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 emitsAccess-Control-Allow-Credentials. A browser will not expose a credentialed (cookie-bearing) cross-origin response unless the server sendsAccess-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: <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=truein production. Leave it unset. -
Set an explicit origin list — either the env var or the Security-tab field:
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). Do not forge
X-Forwarded-For: 127.0.0.1to "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=trueis live, and/api/settings/authz-inventoryreturns acors: { 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:
# 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-Credentialson 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 — 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 — loopback enforcement for spawn-capable routes (a separate, complementary control).
- Authorization Guide — the full auth pipeline.