diff --git a/.env.example b/.env.example index 2021c41ecb..939e5ecc6a 100644 --- a/.env.example +++ b/.env.example @@ -111,6 +111,12 @@ NODE_ENV=production # Default: endpoint-proxy-salt | Change per-deployment for isolation. MACHINE_ID_SALT=endpoint-proxy-salt +# Salt for deriving CLI machine-ID auth tokens (HMAC-SHA256). +# Used by: src/lib/machineToken.ts — rotates the local CLI auth token without +# touching code. Set to a new value to invalidate existing CLI tokens. +# Default: omniroute-cli-auth-v1 +# OMNIROUTE_CLI_SALT=omniroute-cli-auth-v1 + # Set true when running behind HTTPS (reverse proxy with TLS termination). # Used by: src/lib/auth — sets the Secure flag on session cookies. # Default: false | MUST be true in any non-localhost deployment. diff --git a/CLAUDE.md b/CLAUDE.md index 46b087aa5d..7ab969ef4a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -413,3 +413,4 @@ git push -u origin feat/your-feature 12. Never return raw `err.stack` / `err.message` in HTTP / SSE / executor responses — always route through `buildErrorBody()` or `sanitizeErrorMessage()` (`open-sse/utils/error.ts`). See `docs/security/ERROR_SANITIZATION.md`. 13. Never string-interpolate external paths or runtime values into shell scripts passed to `exec()`/`spawn()` — pass via the `env` option instead. Reference: `src/mitm/cert/install.ts::updateNssDatabases`. 14. Never dismiss a CodeQL / Secret-Scanning alert without (a) first checking the pattern docs above to see if the helper applies, and (b) recording the technical justification in the dismissal comment. Precedent: `js/stack-trace-exposure` raised on callsites that already route through `sanitizeErrorMessage()` is a known CodeQL limitation (custom sanitizers not recognized) — dismiss as `false positive` referencing `docs/security/ERROR_SANITIZATION.md`. +15. Never expose routes that spawn child processes (`/api/mcp/`, `/api/cli-tools/runtime/`) without `isLocalOnlyPath()` classification in `src/server/authz/routeGuard.ts`. Loopback enforcement happens unconditionally before any auth check — leaked JWT via tunnel cannot trigger process spawning. See `docs/security/ROUTE_GUARD_TIERS.md`. diff --git a/bin/cli/tray/tray.ps1 b/bin/cli/tray/tray.ps1 index 35815dc00d..bd90f2903b 100644 --- a/bin/cli/tray/tray.ps1 +++ b/bin/cli/tray/tray.ps1 @@ -11,7 +11,19 @@ $ErrorActionPreference = "Stop" $OutputEncoding = [System.Text.Encoding]::UTF8 $script:notifyIcon = New-Object System.Windows.Forms.NotifyIcon -$script:notifyIcon.Icon = New-Object System.Drawing.Icon($IconPath) +if ($IconPath -and (Test-Path $IconPath)) { + if ($IconPath.ToLower().EndsWith('.ico')) { + $script:notifyIcon.Icon = New-Object System.Drawing.Icon($IconPath) + } else { + # Accepts .png and other bitmap formats via GDI+ handle conversion + $bitmap = New-Object System.Drawing.Bitmap($IconPath) + $handle = $bitmap.GetHicon() + $script:notifyIcon.Icon = [System.Drawing.Icon]::FromHandle($handle) + $bitmap.Dispose() + } +} else { + $script:notifyIcon.Icon = [System.Drawing.SystemIcons]::Application +} $script:notifyIcon.Text = $Tooltip $script:notifyIcon.Visible = $true diff --git a/bin/cli/tray/tray.ts b/bin/cli/tray/tray.ts index c904bc17d2..f9e3c642e1 100644 --- a/bin/cli/tray/tray.ts +++ b/bin/cli/tray/tray.ts @@ -29,9 +29,13 @@ const FALLBACK_ICON_BASE64 = export function getIconPath(): string { const isWin = process.platform === "win32"; - const iconFile = isWin ? "icon.ico" : "icon.png"; - const iconPath = join(__dirname, iconFile); - return existsSync(iconPath) ? iconPath : ""; + // On Windows prefer .ico but fall back to .png (tray.ps1 handles both via GDI+) + const candidates = isWin ? ["icon.ico", "icon.png"] : ["icon.png"]; + for (const iconFile of candidates) { + const iconPath = join(__dirname, iconFile); + if (existsSync(iconPath)) return iconPath; + } + return ""; } export function getIconBase64(): string { diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index b64fdee1f5..839c04c3e9 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -148,17 +148,18 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari ## 4. Security & Authentication -| Variable | Default | Source File | Description | -| --------------------------------------- | --------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `MACHINE_ID_SALT` | `endpoint-proxy-salt` | `src/lib/auth` | Salt combined with hardware identifiers for machine fingerprinting. Change per-deployment for isolation. | -| `AUTH_COOKIE_SECURE` | `false` | `src/lib/auth` | Sets the `Secure` flag on session cookies. **Must be `true`** when running behind HTTPS. | -| `REQUIRE_API_KEY` | `false` | API middleware | When `true`, all `/v1/*` proxy requests must include a valid API key. | -| `ALLOW_API_KEY_REVEAL` | `false` | Dashboard providers page | Allows revealing full API key values in the Dashboard UI. Security risk on shared instances. | -| `NO_LOG_API_KEY_IDS` | _(empty)_ | `src/lib/compliance/index.ts` | Comma-separated API key IDs that bypass request logging (GDPR compliance). | -| `MAX_BODY_SIZE_BYTES` | `10485760` (10 MB) | `src/shared/middleware/bodySizeGuard.ts` | Maximum allowed request body size. Rejects payloads exceeding this limit. | -| `CORS_ORIGIN` | `*` | Next.js middleware | CORS `Access-Control-Allow-Origin` value. Restrict for production. | -| `OUTBOUND_SSRF_GUARD_ENABLED` | `true` | `src/shared/network/outboundUrlGuard.ts` | Block provider calls targeting private/loopback/link-local IP ranges. Disable only in isolated test envs. | -| `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS` | `false` | `src/shared/network/outboundUrlGuard.ts` | Allow provider URLs pointing to private/local networks (localhost, 192.168.x.x, 10.x.x.x, etc.). **REQUIRED for self-hosted providers** (LM Studio, Ollama, vLLM, Llamafile, Triton, SearXNG). When `false`, the dashboard rejects validation of local URLs. | +| Variable | Default | Source File | Description | +| --------------------------------------- | ----------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `MACHINE_ID_SALT` | `endpoint-proxy-salt` | `src/lib/auth` | Salt combined with hardware identifiers for machine fingerprinting. Change per-deployment for isolation. | +| `OMNIROUTE_CLI_SALT` | `omniroute-cli-auth-v1` | `src/lib/machineToken.ts` | HMAC salt for deriving the local CLI auth token. Changing this value rotates all CLI tokens on the machine. See `docs/security/CLI_TOKEN.md`. | +| `AUTH_COOKIE_SECURE` | `false` | `src/lib/auth` | Sets the `Secure` flag on session cookies. **Must be `true`** when running behind HTTPS. | +| `REQUIRE_API_KEY` | `false` | API middleware | When `true`, all `/v1/*` proxy requests must include a valid API key. | +| `ALLOW_API_KEY_REVEAL` | `false` | Dashboard providers page | Allows revealing full API key values in the Dashboard UI. Security risk on shared instances. | +| `NO_LOG_API_KEY_IDS` | _(empty)_ | `src/lib/compliance/index.ts` | Comma-separated API key IDs that bypass request logging (GDPR compliance). | +| `MAX_BODY_SIZE_BYTES` | `10485760` (10 MB) | `src/shared/middleware/bodySizeGuard.ts` | Maximum allowed request body size. Rejects payloads exceeding this limit. | +| `CORS_ORIGIN` | `*` | Next.js middleware | CORS `Access-Control-Allow-Origin` value. Restrict for production. | +| `OUTBOUND_SSRF_GUARD_ENABLED` | `true` | `src/shared/network/outboundUrlGuard.ts` | Block provider calls targeting private/loopback/link-local IP ranges. Disable only in isolated test envs. | +| `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS` | `false` | `src/shared/network/outboundUrlGuard.ts` | Allow provider URLs pointing to private/local networks (localhost, 192.168.x.x, 10.x.x.x, etc.). **REQUIRED for self-hosted providers** (LM Studio, Ollama, vLLM, Llamafile, Triton, SearXNG). When `false`, the dashboard rejects validation of local URLs. | ### Hardening Checklist diff --git a/docs/security/CLI_TOKEN.md b/docs/security/CLI_TOKEN.md new file mode 100644 index 0000000000..3a5ac77440 --- /dev/null +++ b/docs/security/CLI_TOKEN.md @@ -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:/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 diff --git a/docs/security/ROUTE_GUARD_TIERS.md b/docs/security/ROUTE_GUARD_TIERS.md new file mode 100644 index 0000000000..382b55eb08 --- /dev/null +++ b/docs/security/ROUTE_GUARD_TIERS.md @@ -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 diff --git a/open-sse/services/compression/types.ts b/open-sse/services/compression/types.ts index e5bdee7b47..aea6a54a44 100644 --- a/open-sse/services/compression/types.ts +++ b/open-sse/services/compression/types.ts @@ -171,7 +171,16 @@ export const DEFAULT_CAVEMAN_CONFIG: CavemanConfig = { compressRoles: ["user"], skipRules: [], minMessageLength: 50, - preservePatterns: [], + // Protect code blocks, inline code, file paths, URLs, and error/stack lines + // from caveman compression so signal-carrying content is never mangled. + preservePatterns: [ + "```[\\s\\S]*?```", + "`[^`\\n]+`", + "\\b(https?://\\S+)", + "(?:^|\\s)(\\.{0,2}/[\\w./\\-]+)", + "^\\s*(Error|TypeError|RangeError|SyntaxError|ReferenceError):", + "^\\s+at\\s", + ], intensity: "full", }; diff --git a/src/lib/machineToken.ts b/src/lib/machineToken.ts index 5ea3c76f01..af91785bed 100644 --- a/src/lib/machineToken.ts +++ b/src/lib/machineToken.ts @@ -3,23 +3,31 @@ import nodeMachineId from "node-machine-id"; const { machineIdSync } = nodeMachineId; -const DEFAULT_SALT = "omniroute-cli-auth"; +const BUILTIN_DEFAULT_SALT = "omniroute-cli-auth-v1"; + +function getActiveSalt(): string { + return process.env.OMNIROUTE_CLI_SALT || BUILTIN_DEFAULT_SALT; +} function deriveToken(rawId: string, salt: string): string { - return createHmac("sha256", rawId).update(salt).digest("hex").slice(0, 32); + return createHmac("sha256", rawId).update(salt).digest("hex"); } let cached: string | null = null; +let cachedSalt: string | null = null; -export function getMachineTokenSync(salt = DEFAULT_SALT): string { +export function getMachineTokenSync(salt?: string): string { + const activeSalt = salt ?? getActiveSalt(); try { // machineIdSync(true) returns the original unhashed hardware ID. const rawId = machineIdSync(true); - if (salt === DEFAULT_SALT) { - cached ??= deriveToken(rawId, salt); - return cached; + if (activeSalt === cachedSalt && cached !== null) return cached; + const token = deriveToken(rawId, activeSalt); + if (!salt) { + cached = token; + cachedSalt = activeSalt; } - return deriveToken(rawId, salt); + return token; } catch { return ""; } diff --git a/src/server/authz/policies/management.ts b/src/server/authz/policies/management.ts index 63892b88b3..0388a198ad 100644 --- a/src/server/authz/policies/management.ts +++ b/src/server/authz/policies/management.ts @@ -1,3 +1,4 @@ +import { timingSafeEqual } from "node:crypto"; import { isModelSyncInternalRequest } from "../../../shared/services/modelSyncScheduler"; import { isAuthRequired, isDashboardSessionAuthenticated } from "../../../shared/utils/apiAuth"; import { getMachineTokenSync } from "../../../lib/machineToken"; @@ -17,7 +18,8 @@ function hasValidCliToken(headers: Headers): boolean { const provided = headers.get(CLI_TOKEN_HEADER); if (!provided) return false; const expected = getMachineTokenSync(); - return expected !== "" && provided === expected; + if (expected === "" || provided.length !== expected.length) return false; + return timingSafeEqual(Buffer.from(provided), Buffer.from(expected)); } function hasBearerToken(headers: Headers): boolean { diff --git a/tests/unit/lib/machineToken.test.ts b/tests/unit/lib/machineToken.test.ts index 4921456b91..addd7dc7ae 100644 --- a/tests/unit/lib/machineToken.test.ts +++ b/tests/unit/lib/machineToken.test.ts @@ -2,9 +2,9 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { getMachineTokenSync } from "../../../src/lib/machineToken.ts"; -test("getMachineTokenSync returns a 32-character hex string", () => { +test("getMachineTokenSync returns a 64-character hex string (full SHA-256)", () => { const token = getMachineTokenSync(); - assert.match(token, /^[0-9a-f]{32}$/, "token must be 32 lowercase hex chars"); + assert.match(token, /^[0-9a-f]{64}$/, "token must be 64 lowercase hex chars (HMAC-SHA256)"); }); test("getMachineTokenSync is deterministic", () => { @@ -20,3 +20,12 @@ test("getMachineTokenSync produces different values for different salts", () => test("getMachineTokenSync with empty string salt does not throw", () => { assert.doesNotThrow(() => getMachineTokenSync("")); }); + +test("getMachineTokenSync respects OMNIROUTE_CLI_SALT env var", () => { + const before = getMachineTokenSync(); + process.env.OMNIROUTE_CLI_SALT = "__test_salt__"; + const withEnv = getMachineTokenSync(); + delete process.env.OMNIROUTE_CLI_SALT; + assert.notEqual(before, withEnv, "env salt must produce a different token"); + assert.match(withEnv, /^[0-9a-f]{64}$/, "env-derived token must still be 64-char hex"); +});