163 lines
9.4 KiB
Markdown
163 lines
9.4 KiB
Markdown
---
|
|
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:
|
|
|
|
```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.
|