Files
2026-07-13 13:39:12 +08:00

9.4 KiB

title
title
CORS Configuration & Security

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: <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:

    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.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:

# 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.

  • 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 — 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