--- 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](#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 ` 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 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/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 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