fix(security): address code-review findings — timing-safe token, OMNIROUTE_CLI_SALT, tray PNG, preservePatterns defaults, missing docs

- management.ts: replace === with timingSafeEqual for CLI token comparison
- machineToken.ts: salt upgraded to omniroute-cli-auth-v1; OMNIROUTE_CLI_SALT env
  var honoured for rotation; full 64-char SHA-256 hex token
- tray.ps1: accept .png via GDI+ Bitmap->Icon handle; Windows tray works without .ico
- tray.ts: getIconPath() tries icon.ico then icon.png on Windows
- compression/types.ts: DEFAULT_CAVEMAN_CONFIG.preservePatterns filled with
  six defaults (fenced code, inline code, URLs, paths, error lines, stack traces)
- CLAUDE.md: Hard Rule #15 — spawn-capable routes must use isLocalOnlyPath()
- .env.example + docs/reference/ENVIRONMENT.md: document OMNIROUTE_CLI_SALT
- docs/security/CLI_TOKEN.md: new (was referenced in changelog but missing)
- docs/security/ROUTE_GUARD_TIERS.md: new (was referenced in changelog but missing)
- tests/unit/lib/machineToken.test.ts: updated for 64-char token; added
  OMNIROUTE_CLI_SALT env-var rotation test
This commit is contained in:
diegosouzapw
2026-05-15 01:54:09 -03:00
parent 302ea853d5
commit 55659d92be
11 changed files with 228 additions and 26 deletions

View File

@@ -0,0 +1,64 @@
# CLI Machine-ID Token
## Overview
OmniRoute CLI commands authenticate against the local management API using a
`HMAC-SHA256(machine-id, salt)` token sent via the `x-omniroute-cli-token`
request header.
This allows CLI subcommands (`omniroute status`, `omniroute providers`, etc.)
to call management endpoints without requiring the user to supply a JWT or
password on every invocation.
## How it works
1. `getMachineTokenSync()` reads the hardware machine ID via `node-machine-id`
(falls back to an empty string on failure, disabling CLI auth).
2. It computes `HMAC-SHA256(machine_id, salt)` and returns the full 64-char
hex digest — a deterministic, non-reversible token tied to this machine.
3. The CLI sends the token as `x-omniroute-cli-token` on every request to
`http://localhost:<port>/api/...`.
4. The server (`src/server/authz/policies/management.ts`) recomputes the
expected token with the same salt and compares via `timingSafeEqual` to
prevent timing-based extraction.
## Security properties
| Property | Detail |
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| **Loopback-only** | Accepted only when `Host` is `localhost`, `127.0.0.1`, or `::1`. |
| **Constant-time compare** | `crypto.timingSafeEqual` prevents timing attacks. |
| **Non-reversible** | HMAC output cannot recover the machine-id. |
| **No `always`-protected bypass** | `isAlwaysProtectedPath()` is evaluated before the CLI token check. `/api/shutdown` and `/api/settings/database` always require JWT. |
| **Non-exportable** | Token is never written to disk or logged. |
## Salt rotation
Set `OMNIROUTE_CLI_SALT` to rotate the derived token without code changes.
After rotation, all CLI processes on this machine will use the new token
automatically. Useful after a process-list leak that may have exposed the
previous derived value.
```bash
# Persistent rotation (add to shell profile)
export OMNIROUTE_CLI_SALT="my-secret-salt-2026"
# Verify new token is in use
omniroute status
```
Default salt: `omniroute-cli-auth-v1`
## Files
| File | Purpose |
| ----------------------------------------- | ---------------------------------------- |
| `src/lib/machineToken.ts` | Token derivation (`getMachineTokenSync`) |
| `src/server/authz/headers.ts` | `CLI_TOKEN_HEADER` constant |
| `src/server/authz/policies/management.ts` | Server-side verification |
| `src/server/authz/routeGuard.ts` | Loopback host check (`isLoopbackHost`) |
## See also
- `docs/security/ROUTE_GUARD_TIERS.md` — route protection tiers
- `docs/architecture/AUTHZ_GUIDE.md` — full authorization pipeline

View File

@@ -0,0 +1,86 @@
# 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 unconditionally on every request before any auth logic runs.
## Tiers
### Tier 1 — LOCAL_ONLY
**Enforced by:** `isLocalOnlyPath(path)` → loopback host check
**Bypass:** None — not overridable by JWT, CLI token, or `requireLogin=false`
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 |
| ------------------------- | -------------------------------------------------- |
| `/api/mcp/` | MCP server — spawns stdio bridges and SSE handlers |
| `/api/cli-tools/runtime/` | CLI tool runtime — executes plugin code |
**Response on violation:** `403 LOCAL_ONLY`
### 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)?
→ not loopback → 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)
→ reject 401/403
5. requireLogin=false?
→ allow (anonymous)
```
## 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`
## 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 |
## See also
- `docs/security/CLI_TOKEN.md` — CLI machine-ID token
- `docs/architecture/AUTHZ_GUIDE.md` — full authorization pipeline