diff --git a/docs/security/CORS.md b/docs/security/CORS.md new file mode 100644 index 0000000000..557f7fa610 --- /dev/null +++ b/docs/security/CORS.md @@ -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: |*` 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/src/app/(dashboard)/dashboard/settings/components/AuthzSection.tsx b/src/app/(dashboard)/dashboard/settings/components/AuthzSection.tsx index 1c38ced0a4..a77d252c44 100644 --- a/src/app/(dashboard)/dashboard/settings/components/AuthzSection.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/AuthzSection.tsx @@ -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 { @@ -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 && ( +
+ +
+

{t("authz.cors.wildcard.title")}

+

{t("authz.cors.wildcard.desc")}

+
+
+ )} + {/* Bypass policy editor */}
diff --git a/src/app/api/settings/authz-inventory/route.ts b/src/app/api/settings/authz-inventory/route.ts index 880f1af9cb..4b309a992e 100644 --- a/src/app/api/settings/authz-inventory/route.ts +++ b/src/app/api/settings/authz-inventory/route.ts @@ -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 @@ -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); diff --git a/src/server/cors/origins.ts b/src/server/cors/origins.ts index 139966f85e..f6a748fb6a 100644 --- a/src/server/cors/origins.ts +++ b/src/server/cors/origins.ts @@ -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([...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 diff --git a/tests/unit/api/authz-inventory.test.ts b/tests/unit/api/authz-inventory.test.ts index 5d37f44c11..a1363d8026 100644 --- a/tests/unit/api/authz-inventory.test.ts +++ b/tests/unit/api/authz-inventory.test.ts @@ -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"); @@ -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 ───────────────────────────────────────────────────────── @@ -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 () => { diff --git a/tests/unit/cors/origins.test.ts b/tests/unit/cors/origins.test.ts index d00fcf699d..ff1502e02b 100644 --- a/tests/unit/cors/origins.test.ts +++ b/tests/unit/cors/origins.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { NextResponse } from "next/server"; import { applyCorsHeaders, + getCorsStatus, resolveAllowedOrigin, setRuntimeAllowedOrigins, STATIC_CORS_HEADERS, @@ -188,6 +189,48 @@ describe("cors/origins.applyCorsHeaders", () => { }); }); +describe("cors/origins.getCorsStatus", () => { + let envSnap: Record; + + 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( diff --git a/tests/unit/ui/authz-cors-banner.test.tsx b/tests/unit/ui/authz-cors-banner.test.tsx new file mode 100644 index 0000000000..8b9b2c2d1f --- /dev/null +++ b/tests/unit/ui/authz-cors-banner.test.tsx @@ -0,0 +1,104 @@ +// @vitest-environment jsdom +/** + * UI unit test for the AuthzSection wildcard-CORS banner (#5602). + * + * The banner must appear when `/api/settings/authz-inventory` reports + * `cors.allowAll === true` (i.e. `CORS_ALLOW_ALL=true` at runtime) and stay + * hidden otherwise. It is the only runtime signal a wildcard-CORS + * misconfiguration is live. See docs/security/CORS.md. + */ +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Stable identity fn — the real next-intl `t` is memoized per render. A fresh +// closure each render would flip AuthzSection's `useCallback([t])` dep and loop +// its mount fetch forever, so we return the SAME reference every call. +const translate = (key: string) => key; +vi.mock("next-intl", () => ({ + useTranslations: () => translate, +})); + +// Import the (heavy) dashboard component ONCE at module load rather than inside every +// render call. The dynamic import pulls the whole settings-page dependency graph through +// esbuild on first use (~20s cold); doing it per-test made the first test tip over the +// 30s per-test timeout while the second (warm, cached) passed. Hoisting moves that cost to +// module-eval time (outside any per-test timeout) and keeps each test body fast. +const AuthzSection = ( + await import("../../../src/app/(dashboard)/dashboard/settings/components/AuthzSection") +).default; + +const cleanupCallbacks: Array<() => void> = []; + +function makeContainer(): HTMLElement { + const container = document.createElement("div"); + document.body.appendChild(container); + cleanupCallbacks.push(() => container.remove()); + return container; +} + +function inventoryPayload(cors: { allowAll: boolean; allowedOrigins: string[] }) { + return { + tiers: [ + { + name: "PUBLIC", + prefixes: ["/api/health"], + description: "public", + bypassable: false, + }, + ], + bypassEnabled: true, + bypassPrefixes: ["/api/mcp/"], + spawnCapablePrefixes: ["/api/cli-tools/runtime/"], + cors, + }; +} + +function mockInventoryFetch(cors: { allowAll: boolean; allowedOrigins: string[] }) { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(inventoryPayload(cors)), + } as unknown as Response); +} + +async function renderAuthzSection(): Promise { + const container = makeContainer(); + await act(async () => { + createRoot(container).render(React.createElement(AuthzSection)); + }); + // Flush the mount-time inventory fetch + resulting state update. + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + return container; +} + +describe("AuthzSection wildcard-CORS banner (#5602)", { timeout: 60000 }, () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + }); + + afterEach(() => { + while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + it("renders the banner when cors.allowAll is true", async () => { + mockInventoryFetch({ allowAll: true, allowedOrigins: [] }); + await renderAuthzSection(); + const banner = document.querySelector('[data-testid="cors-wildcard-banner"]'); + expect(banner).not.toBeNull(); + }); + + it("does NOT render the banner when cors.allowAll is false", async () => { + mockInventoryFetch({ allowAll: false, allowedOrigins: ["https://app.example.com"] }); + await renderAuthzSection(); + const banner = document.querySelector('[data-testid="cors-wildcard-banner"]'); + expect(banner).toBeNull(); + }); +});