Back to Omniroute

Route Guard Tiers

docs/security/ROUTE_GUARD_TIERS.md

3.8.4917.4 KB
Original Source

Route Guard Tiers

Overview

All OmniRoute management API routes are classified into one of three protection tiers. Classification is static, defined in src/server/authz/routeGuard.ts, and evaluated before any other auth branch runs.

Tiers

Tier 1 — LOCAL_ONLY

Enforced by: isLocalOnlyPath(path) → loopback host check Bypass: None by default. Narrow carve-out for paths in LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES when the request carries a valid API key with the manage scope (see Manage-scope carve-out).

These routes spawn child processes or execute runtime code. Exposing them to non-loopback traffic would allow an attacker who obtained a valid JWT (e.g., via a Cloudflared/Ngrok tunnel) to trigger process spawning — a known CVE class (GHSA-fhh6-4qxv-rpqj).

What GHSA-fhh6-4qxv-rpqj is (the attack class): a management/agent server exposes an endpoint that launches a subprocess (npm install, node, a browser, a proxy, git, tar, …). If that endpoint is reachable from off-host — because the operator put OmniRoute behind an nginx/Cloudflare/Tailscale tunnel and a JWT leaked, or auth was misconfigured — the attacker turns "call an API" into "run a command on the host" (remote code execution). OmniRoute closes this by enforcing a loopback host check unconditionally, before any auth check, on every spawn-capable route: a leaked token over a tunnel still can't reach the spawn.

The full LOCAL_ONLY set. The authoritative source is LOCAL_ONLY_API_PREFIXES / LOCAL_ONLY_API_PATTERNS in src/server/authz/routeGuard.ts; the table below mirrors the current state. The check-route-guard-membership gate enumerates every route.ts under the spawn-capable prefixes and fails CI if any is not classified local-only.

Prefix / patternWhy it's local-onlyManage-scope bypassable?
/api/mcp/MCP server — spawns stdio bridges + SSE handlersYes (only one)
/api/cli-tools/runtime/CLI tool runtime — executes arbitrary plugin codeNo — spawn-capable
/api/services/Embedded services (9router/CLIProxy) — npm install + spawnNo — spawn-capable
/dashboard/providers/services/Reverse proxy to embedded-service UIsNo
/api/copilot/Unauthenticated LLM driver — CLI-only by defaultOperator opt-in: manage/admin
/api/tools/agent-bridge/AgentBridge — spawns MITM server + DNS editsNo — spawn-capable
/api/tools/traffic-inspector/Traffic Inspector — http-proxy listener + system proxyNo — spawn-capable
/api/plugins/, /api/pluginsPlugins — load/execute via worker_threads + child_processNo — spawn-capable
/api/system/versionAuto-update (POST only; GET/HEAD/OPTIONS exempt) — spawns git checkout + npm installNo
/api/db-backups/exportAllSpawns tar for the export archiveNo
/api/local/1-click local launchers (Redis today) — spawns podman/dockerNo — spawn-capable
/api/headroom/start, /stopHeadroom proxy lifecycle — spawns python CLI / signals PIDNo — spawn-capable
/api/oauth/cursor/auto-importexecFile("which", ["cursor"]) before importing credsNo
/api/providers/{id}/login (regex)Launches a headful Playwright Chromium for web-cookie loginNo
/api/providers/{id}/refresh-cursor (regex)Manual Cursor session renewal — nudges cursor-agent (--list-models/status via src/lib/cursor/renewal.ts); the rest of /api/providers/, including the generic /refresh, intentionally stays remote-reachableNo — spawn-capable
/api/providers/cursor/agent-availabilityDashboard install-nudge check — spawns cursor-agent status --format json via checkCursorAgentAvailability()/getCachedCursorAgentAvailability() (src/lib/cursor/renewal.ts); credential-free response ({cursorAgentAvailable: boolean} only)No — spawn-capable

Response on violation: 403 LOCAL_ONLY

Manage-scope carve-out

A subset of LOCAL_ONLY paths MAY also be accessed from non-loopback if and only if the request carries an Authorization: Bearer <api-key> whose metadata includes the manage scope (or admin). The carve-out is gated explicitly per-path via LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES so the default for any new LOCAL_ONLY path remains strict-loopback. Unauthenticated requests and requests with non-manage keys are still rejected with 403 LOCAL_ONLY.

Today the only bypassable prefix is /api/mcp/. /api/cli-tools/runtime/ and /api/services/ are intentionally excluded because they can spawn arbitrary subprocesses (npm install, node), which is the exact CVE class the LOCAL_ONLY tier exists to prevent.

#7895 — mcp:connect narrow scope: the /api/mcp/ carve-out ALSO accepts a Bearer key holding the narrow mcp:connect scope (src/shared/constants/managementScopes.ts::MCP_CONNECT_SCOPE), checked via hasMcpConnectOrManageScope() in src/server/authz/policies/management.ts. This is scoped to /api/mcp/ ONLY — mcp:connect grants nothing on any other management route (including every other LOCAL_ONLY bypass prefix, should one ever be added), and it is deliberately excluded from MANAGEMENT_API_KEY_SCOPES. A key holding manage/admin still passes the carve-out exactly as before; mcp:connect is a lower-privilege alternative for remote MCP-only callers who should not need broad management access.

RequestPathResult
Non-loopback, no Bearer/api/mcp/*403 LOCAL_ONLY
Non-loopback, Bearer with manage scope/api/mcp/*Allow
Non-loopback, Bearer with mcp:connect scope/api/mcp/*Allow
Non-loopback, Bearer without manage/mcp:connect/api/mcp/*403 LOCAL_ONLY
Non-loopback, Bearer with mcp:connect scope/api/cli-tools/runtime/*403 LOCAL_ONLY
Non-loopback, Bearer with manage scope/api/cli-tools/runtime/*403 LOCAL_ONLY
Loopback, any/no Bearerany LOCAL_ONLYAllow (gate passes)

Operator guidance & auditing

If you run OmniRoute behind a reverse proxy or tunnel (nginx, Caddy, Cloudflare Tunnel, Tailscale, Ngrok), the loopback check still protects the spawn-capable routes above — a request whose client address is non-loopback is rejected with 403 LOCAL_ONLY before auth runs, so a leaked JWT can't reach a spawn. Two operator responsibilities remain:

  • Do not "fix" a 403 by forging the client IP as loopback. Setting X-Forwarded-For: 127.0.0.1, or a proxy that rewrites the source address to loopback, re-opens exactly the RCE class this tier closes. Expose the dashboard/API through the proxy — never the spawn-capable routes.
  • Keep the manage-scope bypass minimal. Only /api/mcp/ is bypassable, and only with a manage-scoped API key. The SPAWN_CAPABLE_PREFIXES can never be added to the bypass list — the zod schema rejects them and isLocalOnlyBypassableByManageScope denies them at runtime (defence-in-depth), which is what the dashboard means by "cannot be made bypassable". Dynamic-segment and static-path spawn-capable routes under /api/providers/ (e.g. /login, /refresh-cursor) are covered by the regex-based SPAWN_CAPABLE_PATTERNS / SPAWN_CAPABLE_PATTERN_ANCESTORS companion in src/shared/constants/spawnCapablePrefixes.ts, not by the flat SPAWN_CAPABLE_PREFIXES array — the flat array would have to cover the entire /api/providers/ prefix to catch them, over-broadening a route tree remote dashboards legitimately use for provider CRUD.

Auditing access — to verify nothing off-host is reaching these routes:

  • Open the Authorization Inventory on /dashboard/settings/security: it renders the live LOCAL_ONLY prefix list, which prefixes are bypassable, and the compile-time spawn-capable ("cannot be made bypassable") set.
  • Grep your reverse-proxy / access logs for the prefixes above paired with a non-loopback client address. Any such hit that returned 200 instead of 403 LOCAL_ONLY means the proxy is masking the real client IP — fix the proxy.
  • A 403 LOCAL_ONLY in OmniRoute's logs for one of these paths is the guard working as intended, not an error to suppress.

Tier 2 — ALWAYS_PROTECTED

Enforced by: isAlwaysProtectedPath(path) → skip requireLogin=false bypass Bypass: None when requireLogin=false; JWT always required

These routes are destructive or irreversible. Allowing them in a "no-password" install would mean anyone on the same LAN could wipe the database or kill the server process.

PathReason
/api/shutdownTerminates the server process
/api/settings/databaseDatabase export, import, and wipe

Response on violation: 401 Authentication required

Tier 3 — MANAGEMENT (default)

All other management routes. Auth required unless requireLogin=false is configured. CLI tokens can authenticate these routes (loopback + valid HMAC).

Evaluation order

managementPolicy.evaluate(ctx)
  1. isLocalOnlyPath(path)?
     → loopback                                  → fall through
     → non-loopback, manage-scope Bearer
        AND isLocalOnlyBypassableByManageScope   → allow (management_key)
     → otherwise                                  → reject 403 LOCAL_ONLY
  2. isInternalModelSyncRequest(ctx)?
     → allow (system)
  3. hasValidCliToken(headers)?
     → allow (cli) [loopback + timingSafeEqual HMAC check]
  4. isAlwaysProtectedPath(path) or requireLogin=true?
     → isDashboardSessionAuthenticated?
        → allow (dashboard_session)
     → manage-scope Bearer on a non-bypassable path?
        → allow (management_key)
     → reject 401/403
  5. requireLogin=false?
     → allow (anonymous)

Step 1's manage-scope branch is the only authenticated path that can satisfy a LOCAL_ONLY route; the auth-backend failure mode returns 503 (not 403) so an expired DB doesn't silently downgrade to "deny".

Adding a new spawn-capable route

  1. Add the path prefix to LOCAL_ONLY_API_PREFIXES in src/server/authz/routeGuard.ts
  2. Add a test in tests/unit/authz/routeGuard.test.ts asserting that isLocalOnlyPath() returns true for the new prefix
  3. Never skip this step — see Hard Rule #15 in CLAUDE.md
  4. Decide: does this route ALSO belong in LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES? Default answer is no. Only opt-in when the route is safe to expose to a manage-scope holder (i.e. does NOT spawn arbitrary user-controlled code).

Adding a manage-scope-bypassable path

  1. Confirm the route does not execute user-supplied code or commands. If it does, stop — this carve-out is the wrong tool.
  2. Append the prefix to LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES in src/server/authz/routeGuard.ts
  3. Add coverage in tests/unit/authz/management-policy.test.ts for all four request shapes: no Bearer (403), manage Bearer (allow), non-manage Bearer (403), and the per-prefix regression that /api/cli-tools/runtime/* stays strict-loopback even with a manage Bearer.

Files

FilePurpose
src/server/authz/routeGuard.tsConstants and helper functions
src/server/authz/policies/management.tsEvaluation logic
tests/unit/authz/routeGuard.test.tsUnit tests for tier helpers
tests/unit/authz/management-policy.test.tsUnit tests for evaluate()

Documenting Security Tiers in OpenAPI

When adding a new route to docs/openapi.yaml, apply the corresponding vendor extension if the route is classified by routeGuard.ts:

routeGuard.ts classificationYAML annotationEnforcement
LOCAL_ONLY_API_PREFIXESx-loopback-only: trueBlocked from non-loopback unconditionally
ALWAYS_PROTECTED_API_PATHSx-always-protected: trueAuth required even with requireLogin=false
Internal admin/debug routex-internal: trueHidden from /dashboard/api-endpoints by default
None (public / standard auth)(no annotation needed)Standard requireLogin-controlled access

Validation

Two scripts enforce consistency between YAML annotations and routeGuard.ts:

  • scripts/check/check-openapi-coverage.mjs — fails if coverage < 99%
  • scripts/check/check-openapi-security-tiers.mjs — fails if x-loopback-only or x-always-protected annotations diverge from the compile-time constants

Both scripts run in the pre-commit hook and in CI.

False Positive Rule

If x-always-protected or x-loopback-only is annotated on a route that is NOT in the routeGuard.ts constant, the coverage script fails. The fix is always to align the YAML to what routeGuard.ts actually enforces — not to add routes to routeGuard.ts without also implementing the enforcement logic.


See also

  • docs/security/CLI_TOKEN.md — CLI machine-ID token
  • docs/architecture/AUTHZ_GUIDE.md — full authorization pipeline
  • docs/frameworks/MCP-SERVER.md — MCP server transports and scopes