feat(dashboard): wildcard-CORS runtime warning + CORS security doc (#5602) (#5759)

Integrated into release/v3.8.44 — wildcard-CORS runtime warning banner + docs/security/CORS.md security guide (#5602). Re-cut clean onto the release tip (branch was fossilized). Validated: 20+9 backend + 2 banner(vitest) tests green, typecheck:core 0, docs-sync/symbols/fabricated/doc-links pass. UNSTABLE red is the inherited environmental setup-claude base-red.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-02 22:15:02 -03:00
committed by GitHub
parent cbd08ef780
commit f7dea02853
7 changed files with 400 additions and 0 deletions

162
docs/security/CORS.md Normal file
View File

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

View File

@@ -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 && (
<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">

View File

@@ -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);

View File

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

View File

@@ -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 () => {

View File

@@ -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<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(

View File

@@ -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<HTMLElement> {
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();
});
});