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) |
/api/services/ | Embedded services (9router, CLIProxy) — npm install+spawn | 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/ 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.
| 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
- Add the path prefix to
LOCAL_ONLY_API_PREFIXESinsrc/server/authz/routeGuard.ts - Add a test in
tests/unit/authz/routeGuard.test.tsasserting thatisLocalOnlyPath()returns true for the new prefix - Never skip this step — see Hard Rule #15 in
CLAUDE.md - 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
- Confirm the route does not execute user-supplied code or commands. If it does, stop — this carve-out is the wrong tool.
- Append the prefix to
LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXESinsrc/server/authz/routeGuard.ts - Add coverage in
tests/unit/authz/management-policy.test.tsfor 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() |
Documenting Security Tiers in OpenAPI
When adding a new route to docs/reference/openapi.yaml, apply the corresponding
vendor extension if the route is classified by routeGuard.ts:
| routeGuard.ts classification | YAML annotation | Enforcement |
|---|---|---|
LOCAL_ONLY_API_PREFIXES | x-loopback-only: true | Blocked from non-loopback unconditionally |
ALWAYS_PROTECTED_API_PATHS | x-always-protected: true | Auth required even with requireLogin=false |
| Internal admin/debug route | x-internal: true | Hidden 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 ifx-loopback-onlyorx-always-protectedannotations 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 tokendocs/architecture/AUTHZ_GUIDE.md— full authorization pipelinedocs/frameworks/MCP-SERVER.md— MCP server transports and scopes