Skip to content

Route Guard Tiers

diegosouzapw edited this page May 23, 2026 · 2 revisions

🌍 View in other languages

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

Prefix Reason Bypassable by manage?
/api/mcp/ MCP server — spawns stdio bridges and SSE handlers Yes
/api/cli-tools/runtime/ CLI tool runtime — executes arbitrary plugin code No (strict-loopback)

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/ is intentionally excluded because it can spawn arbitrary subprocesses, which is the exact CVE class the LOCAL_ONLY tier exists to prevent.

Request Path Result
Non-loopback, no Bearer /api/mcp/* 403 LOCAL_ONLY
Non-loopback, Bearer with manage scope /api/mcp/* Allow
Non-loopback, Bearer without manage scope /api/mcp/* 403 LOCAL_ONLY
Non-loopback, Bearer with manage scope /api/cli-tools/runtime/* 403 LOCAL_ONLY
Loopback, any/no Bearer any LOCAL_ONLY Allow (gate passes)

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.

Path Reason
/api/shutdown Terminates the server process
/api/settings/database Database 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

File Purpose
src/server/authz/routeGuard.ts Constants and helper functions
src/server/authz/policies/management.ts Evaluation logic
tests/unit/authz/routeGuard.test.ts Unit tests for tier helpers
tests/unit/authz/management-policy.test.ts Unit tests for evaluate()

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

Clone this wiki locally