Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions docs/security/CORS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
---
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.
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,17 @@ interface TierEntry {
bypassable: boolean;
}

interface CorsStatus {
allowAll: boolean;
allowedOrigins: string[];
}

interface InventoryPayload {
tiers: TierEntry[];
bypassEnabled: boolean;
bypassPrefixes: string[];
spawnCapablePrefixes: string[];
cors?: CorsStatus;
}

interface StatusMessage {
Expand Down Expand Up @@ -241,6 +247,24 @@ export default function AuthzSection() {

return (
<>
{/* #5602: persistent wildcard-CORS warning — only visible while
CORS_ALLOW_ALL=true is live. See docs/security/CORS.md. */}
{inventory.cors?.allowAll && (
<div
data-testid="cors-wildcard-banner"
role="alert"
className="flex items-start gap-3 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-amber-700 dark:text-amber-300"
>
<span className="material-symbols-outlined text-[20px] mt-0.5" aria-hidden="true">
warning
</span>
<div className="flex flex-col gap-1">
<p className="font-semibold">{t("authz.cors.wildcard.title")}</p>
<p className="text-sm">{t("authz.cors.wildcard.desc")}</p>
</div>
</div>
)}

{/* Bypass policy editor */}
<Card>
<div className="flex items-center gap-3 mb-4">
Expand Down
4 changes: 4 additions & 0 deletions src/app/api/settings/authz-inventory/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES,
SPAWN_CAPABLE_PREFIXES,
} from "@/server/authz/routeGuard";
import { getCorsStatus } from "@/server/cors/origins";

/**
* Static MANAGEMENT-tier example prefixes. Render-only — never consulted by
Expand Down Expand Up @@ -153,6 +154,9 @@ export async function GET(request: Request) {
bypassEnabled,
bypassPrefixes,
spawnCapablePrefixes: [...SPAWN_CAPABLE_PREFIXES],
// #5602: surface the effective CORS allowlist so the dashboard can warn
// when `CORS_ALLOW_ALL=true` is set (wildcard origins). See docs/security/CORS.md.
cors: getCorsStatus(),
});
} catch (error) {
console.log("Error loading authz inventory:", error);
Expand Down
22 changes: 22 additions & 0 deletions src/server/cors/origins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,28 @@ export function resolveAllowedOrigin(requestOrigin: string | null | undefined):
return null;
}

/**
* Read-only snapshot of the effective CORS allowlist configuration, for
* dashboards / monitoring (`/api/settings/authz-inventory`). `allowAll` mirrors
* the `CORS_ALLOW_ALL` opt-in (and the legacy `CORS_ORIGIN=*`); `allowedOrigins`
* is the merged, normalized, sorted, deduped env + runtime allowlist.
*
* A `true` `allowAll` is what the dashboard surfaces as a wildcard-CORS warning.
* See `docs/security/CORS.md`.
*/
export interface CorsStatus {
allowAll: boolean;
allowedOrigins: string[];
}

export function getCorsStatus(): CorsStatus {
const merged = new Set<string>([...envAllowedOrigins(), ...runtimeAllowedOrigins]);
return {
allowAll: envAllowAll(),
allowedOrigins: [...merged].sort(),
};
}

/**
* Apply CORS headers to a response in-place. Safe to call on any response
* (rejections, preflight, normal `next()` continuations). When the origin
Expand Down
41 changes: 41 additions & 0 deletions tests/unit/api/authz-inventory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ process.env.OMNIROUTE_DISABLE_REDIS_AUTH_CACHE = "1";

const ORIGINAL_JWT_SECRET = process.env.JWT_SECRET;
const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD;
const ORIGINAL_CORS_ALLOW_ALL = process.env.CORS_ALLOW_ALL;

const core = await import("../../../src/lib/db/core.ts");
const settingsDb = await import("../../../src/lib/db/settings.ts");
Expand All @@ -39,6 +40,8 @@ test.after(() => {
else process.env.JWT_SECRET = ORIGINAL_JWT_SECRET;
if (ORIGINAL_INITIAL_PASSWORD === undefined) delete process.env.INITIAL_PASSWORD;
else process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD;
if (ORIGINAL_CORS_ALLOW_ALL === undefined) delete process.env.CORS_ALLOW_ALL;
else process.env.CORS_ALLOW_ALL = ORIGINAL_CORS_ALLOW_ALL;
});

// ─── AC-1 — shape ─────────────────────────────────────────────────────────
Expand Down Expand Up @@ -153,6 +156,44 @@ test("AC-2: bypassPrefixes additions land in the inventory", async () => {
assert.deepEqual(body.bypassPrefixes, ["/api/mcp/", "/api/mcp/v2/"]);
});

// ─── #5602 — CORS status surfaced for the dashboard wildcard warning ──────

test("#5602: cors.allowAll is false by default (no CORS_ALLOW_ALL)", async () => {
process.env.JWT_SECRET = "test-jwt-secret-authz-inventory";
process.env.INITIAL_PASSWORD = "initial-pass-cors-a";
delete process.env.CORS_ALLOW_ALL;
await settingsDb.updateSettings({ requireLogin: true });

const response = await inventoryRoute.GET(
await makeManagementSessionRequest("http://localhost/api/settings/authz-inventory", {
method: "GET",
})
);
assert.equal(response.status, 200);
const body = (await response.json()) as {
cors: { allowAll: boolean; allowedOrigins: string[] };
};
assert.ok(body.cors, "response should carry a cors envelope");
assert.equal(body.cors.allowAll, false);
assert.deepEqual(body.cors.allowedOrigins, []);
});

test("#5602: cors.allowAll reflects CORS_ALLOW_ALL=true", async () => {
process.env.JWT_SECRET = "test-jwt-secret-authz-inventory";
process.env.INITIAL_PASSWORD = "initial-pass-cors-b";
process.env.CORS_ALLOW_ALL = "true";
await settingsDb.updateSettings({ requireLogin: true });

const response = await inventoryRoute.GET(
await makeManagementSessionRequest("http://localhost/api/settings/authz-inventory", {
method: "GET",
})
);
assert.equal(response.status, 200);
const body = (await response.json()) as { cors: { allowAll: boolean } };
assert.equal(body.cors.allowAll, true);
});

// ─── AC-12 — anonymous request rejected (no inventory leak) ───────────────

test("AC-12: anonymous request (no cookie, no Bearer) → 401", async () => {
Expand Down
43 changes: 43 additions & 0 deletions tests/unit/cors/origins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import { NextResponse } from "next/server";
import {
applyCorsHeaders,
getCorsStatus,
resolveAllowedOrigin,
setRuntimeAllowedOrigins,
STATIC_CORS_HEADERS,
Expand Down Expand Up @@ -188,6 +189,48 @@ describe("cors/origins.applyCorsHeaders", () => {
});
});

describe("cors/origins.getCorsStatus", () => {
let envSnap: Record<string, string | undefined>;

beforeEach(() => {
envSnap = snapshotEnv();
for (const key of ENV_KEYS) delete process.env[key];
setRuntimeAllowedOrigins("");
});

afterEach(() => {
restoreEnv(envSnap);
setRuntimeAllowedOrigins("");
});

it("default (no env, no runtime) → allowAll false, empty origins", () => {
assert.deepEqual(getCorsStatus(), { allowAll: false, allowedOrigins: [] });
});

it("CORS_ALLOW_ALL=true → allowAll true", () => {
process.env.CORS_ALLOW_ALL = "true";
assert.equal(getCorsStatus().allowAll, true);
});

it("legacy CORS_ORIGIN=* → allowAll true", () => {
process.env.CORS_ORIGIN = "*";
assert.equal(getCorsStatus().allowAll, true);
});

it("merges env + runtime allowlists, normalized, sorted, deduped", () => {
process.env.CORS_ALLOWED_ORIGINS = "https://Env.Example.com/, https://shared.example.com";
setRuntimeAllowedOrigins("https://runtime.example.com, https://shared.example.com/");
assert.deepEqual(getCorsStatus(), {
allowAll: false,
allowedOrigins: [
"https://env.example.com",
"https://runtime.example.com",
"https://shared.example.com",
],
});
});
});

describe("cors/origins.STATIC_CORS_HEADERS", () => {
it("never contains Access-Control-Allow-Origin", () => {
assert.equal(
Expand Down
Loading
Loading