mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 21:32:10 +03:00
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:
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
64
docs/security/CLI_TOKEN.md
Normal file
64
docs/security/CLI_TOKEN.md
Normal 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
|
||||
86
docs/security/ROUTE_GUARD_TIERS.md
Normal file
86
docs/security/ROUTE_GUARD_TIERS.md
Normal 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
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user