Files
OmniRoute/docs/security/ROUTE_GUARD_TIERS.md
Diego Rodrigues de Sa e Souza 6602af7478 feat: narrow mcp:connect scope + per-key HTTP tool-scope binding (#7895) (#7967)
* feat: narrow mcp:connect scope + per-key HTTP tool-scope binding (#7895)

Adds MCP_CONNECT_SCOPE ("mcp:connect"), a narrow additive API-key scope
(kept out of MANAGEMENT_API_KEY_SCOPES, same precedent as SELF_USAGE_SCOPE)
that authorizes ONLY the /api/mcp/ LOCAL_ONLY route-guard carve-out --
remote MCP-only callers no longer need broad manage/admin scope just to
reach the transport routes. Scoped strictly to /api/mcp/; every other
LOCAL_ONLY bypass prefix still requires hasManageScope() unchanged.

Also resolves the caller's real api_keys.scopes over HTTP/SSE
(httpAuthContext.ts::resolveMcpCallerAuthInfo) and passes it to the MCP
SDK's transport.handleRequest(req, { authInfo }), so extra.authInfo.scopes
reaching tool calls reflects the Bearer key's own scopes instead of the
OMNIROUTE_MCP_SCOPES env fallback -- scopeEnforcement.ts already prioritized
authInfo, it was simply unfed over HTTP. Does not flip the
OMNIROUTE_MCP_ENFORCE_SCOPES default; stdio is unaffected (no per-caller
identity, stays on the meta/env fallback chain).

Closes #7895

* test(mcp): register mcp-connect-scope test in stryker tap.testFiles (#7895)
2026-07-22 02:34:54 -03:00

14 KiB

title
title
Route Guard Tiers

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 / pattern Why it's local-only Manage-scope bypassable?
/api/mcp/ MCP server — spawns stdio bridges + SSE handlers Yes (only one)
/api/cli-tools/runtime/ CLI tool runtime — executes arbitrary plugin code No — spawn-capable
/api/services/ Embedded services (9router/CLIProxy) — npm install + spawn No — spawn-capable
/dashboard/providers/services/ Reverse proxy to embedded-service UIs No
/api/copilot/ Unauthenticated LLM driver — CLI-only by default Operator opt-in: manage/admin
/api/tools/agent-bridge/ AgentBridge — spawns MITM server + DNS edits No — spawn-capable
/api/tools/traffic-inspector/ Traffic Inspector — http-proxy listener + system proxy No — spawn-capable
/api/plugins/, /api/plugins Plugins — load/execute via worker_threads + child_process No — spawn-capable
/api/system/version Auto-update (POST only; GET/HEAD/OPTIONS exempt) — spawns git checkout + npm install No
/api/db-backups/exportAll Spawns tar for the export archive No
/api/local/ 1-click local launchers (Redis today) — spawns podman/docker No — spawn-capable
/api/headroom/start, /stop Headroom proxy lifecycle — spawns python CLI / signals PID No — spawn-capable
/api/oauth/cursor/auto-import execFile("which", ["cursor"]) before importing creds No
/api/providers/{id}/login (regex) Launches a headful Playwright Chromium for web-cookie login No

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.

Request Path Result
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 Bearer any LOCAL_ONLY Allow (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".

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.

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

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