Compare commits

...

1 Commits

Author SHA1 Message Date
diegosouzapw
7b322d410b fix(security): generate a random per-install CLI token salt (#13679)
Both src/lib/machineToken.ts::getActiveSalt() and its mirror in
bin/cli/utils/cliToken.mjs derived the CLI/management bearer token as
HMAC-SHA256(raw machine-id, salt) with a checked-in literal default salt
("omniroute-cli-auth-v1"). Since /etc/machine-id is commonly world-readable,
any local user who never set OMNIROUTE_CLI_SALT could derive the same
bearer token as the server.

getActiveSalt() now generates a random 64-char-hex salt on first use and
persists it under <DATA_DIR>/cli-token-salt.json (falling back to the
literal only when neither the env override nor a persisted/writable salt
can be established). Both implementations use the same resolution order
and the same wx-flag create-race handling so the CLI and server keep
deriving the same token. OMNIROUTE_CLI_SALT stays the explicit operator
override, unchanged.

Regression test: tests/unit/machine-token-random-salt-13679.test.ts
2026-09-16 14:02:45 -03:00
6 changed files with 276 additions and 12 deletions

View File

@@ -1,15 +1,86 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { resolveDataDir } from "../data-dir.mjs";
// #13679 PR B: checked-in literal, used ONLY as a last-resort fallback (see
// getActiveSalt() below) — /etc/machine-id is commonly world-readable, so relying on
// this literal as the real default let any local user derive the same bearer token.
const BUILTIN_DEFAULT_SALT = "omniroute-cli-auth-v1";
const SALT_FILE_NAME = "cli-token-salt.json";
const PERSISTED_SALT_RE = /^[0-9a-f]{64}$/;
export const CLI_TOKEN_HEADER = "x-omniroute-cli-token";
let _cached = null;
let _cachedSalt = null;
let _cachedActiveSalt = null;
/** A `node --test` (or vitest) process that never opted into an explicit DATA_DIR must
* not write a salt file into the operator's real home directory. Mirrors
* dataPaths.ts::isTestContext() on the TS side. */
function isTestContext() {
return (
process.env.NODE_ENV === "test" ||
!!process.env.VITEST ||
!!process.env.NODE_TEST_CONTEXT ||
process.execArgv.includes("--test") ||
process.argv.includes("--test")
);
}
function saltFilePath(dataDir) {
return path.join(dataDir, SALT_FILE_NAME);
}
function readPersistedSalt(filePath) {
try {
const raw = fs.readFileSync(filePath, "utf8");
const parsed = JSON.parse(raw);
const salt = parsed && typeof parsed === "object" ? parsed.salt : undefined;
if (typeof salt === "string" && PERSISTED_SALT_RE.test(salt)) return salt;
} catch {
// Missing, unreadable, or corrupt — fall through to (re)generation.
}
return null;
}
/** Mirrors establishPersistedSalt() in src/lib/machineToken.ts — same resolution
* order, same salt file, same `wx`-flag create-race handling — so the CLI and the
* server converge on the same bearer token (docs/security/CLI_TOKEN.md). */
function establishPersistedSalt(dataDir) {
const filePath = saltFilePath(dataDir);
const existing = readPersistedSalt(filePath);
if (existing) return existing;
const generated = crypto.randomBytes(32).toString("hex");
try {
fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(filePath, JSON.stringify({ salt: generated }), { flag: "wx", mode: 0o600 });
return generated;
} catch (err) {
if (err && err.code === "EEXIST") return readPersistedSalt(filePath);
return null;
}
}
/** Mirrors getActiveSalt() in src/lib/machineToken.ts so a rotated
* OMNIROUTE_CLI_SALT reaches the CLI too (docs/security/CLI_TOKEN.md). */
function getActiveSalt() {
return process.env.OMNIROUTE_CLI_SALT || BUILTIN_DEFAULT_SALT;
const envSalt = process.env.OMNIROUTE_CLI_SALT;
if (envSalt) return envSalt;
if (_cachedActiveSalt) return _cachedActiveSalt;
const hasExplicitDataDir = !!(process.env.DATA_DIR && process.env.DATA_DIR.trim());
if (!hasExplicitDataDir && isTestContext()) {
_cachedActiveSalt = BUILTIN_DEFAULT_SALT;
return _cachedActiveSalt;
}
const dataDir = resolveDataDir();
const persisted = establishPersistedSalt(dataDir);
_cachedActiveSalt = persisted || BUILTIN_DEFAULT_SALT;
return _cachedActiveSalt;
}
export function deriveCliToken(machineIdModule, salt) {
@@ -19,8 +90,7 @@ export function deriveCliToken(machineIdModule, salt) {
// undefined and calling it throws — which the catch below turned into an
// empty token, silently disabling CLI auth for every management request.
// Same resolution order as src/lib/machineToken.ts.
const machineIdSync =
machineIdModule?.machineIdSync || machineIdModule?.default?.machineIdSync;
const machineIdSync = machineIdModule?.machineIdSync || machineIdModule?.default?.machineIdSync;
if (typeof machineIdSync !== "function") return "";
// machineIdSync(true) returns the original unhashed hardware ID — mirrors
// getMachineTokenSync() in src/lib/machineToken.ts (#10148 cliToken hardening).

View File

@@ -0,0 +1 @@
- **fix(security):** the CLI/management bearer token is now derived from a random per-install salt persisted under `DATA_DIR` instead of the checked-in literal `omniroute-cli-auth-v1` — since `/etc/machine-id` is commonly world-readable, any local user could previously derive the same token as every install that never set `OMNIROUTE_CLI_SALT`; the explicit env override still takes priority and rotation still works the same way (#13679)

View File

@@ -216,7 +216,7 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari
| 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`. |
| `OMNIROUTE_CLI_SALT` | _(unset = random per-install salt persisted at `<DATA_DIR>/cli-token-salt.json`)_ | `src/lib/machineToken.ts` | HMAC salt for deriving the local CLI auth token. Setting this value rotates all CLI tokens on the machine and always takes priority over the persisted salt. 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` | `src/shared/constants/featureFlagDefinitions.ts` | Allows revealing full API key values in the Dashboard UI. Configurable from Dashboard Feature Flags; security risk on shared instances. |

View File

@@ -41,12 +41,26 @@ password on every invocation.
| **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. |
## Default salt (random per install)
When `OMNIROUTE_CLI_SALT` is not set, the salt is a random 64-char hex string
generated once and persisted at `<DATA_DIR>/cli-token-salt.json` (mode `0600`) —
not the checked-in literal `omniroute-cli-auth-v1`. Both `getActiveSalt()` in
`src/lib/machineToken.ts` and its mirror in `bin/cli/utils/cliToken.mjs` read the
same file, so the server and every CLI invocation on this install converge on the
same value; the checked-in literal is used only as a last-resort fallback when no
persisted or env salt can be established yet (for example a fresh CLI-only install
before the server has ever run). This closes a weakness of the old fixed literal
default: `/etc/machine-id` is commonly world-readable, so any local user could
otherwise derive the same token for every install that never set
`OMNIROUTE_CLI_SALT`.
## 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.
Set `OMNIROUTE_CLI_SALT` to rotate the derived token without code changes — it
always takes priority over the persisted per-install salt. 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)
@@ -56,8 +70,6 @@ export OMNIROUTE_CLI_SALT="my-secret-salt-2026"
omniroute status
```
Default salt: `omniroute-cli-auth-v1`
## Legacy format (SHA-256, 32-char) — still accepted
Before the HMAC format above, the CLI derived its token as
@@ -81,6 +93,8 @@ user on the same host could compute the same token.
| File | Purpose |
| ----------------------------------------- | ---------------------------------------- |
| `src/lib/machineToken.ts` | Token derivation (`getMachineTokenSync`) |
| `bin/cli/utils/cliToken.mjs` | CLI-side mirror of the same derivation |
| `<DATA_DIR>/cli-token-salt.json` | Persisted random per-install salt |
| `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`) |

View File

@@ -1,5 +1,8 @@
import { createHash, createHmac } from "node:crypto";
import { createHash, createHmac, randomBytes } from "node:crypto";
import * as fs from "node:fs";
import * as path from "node:path";
import * as nodeModule from "node:module";
import { resolveDataDir, isTestContext } from "./dataPaths";
let machineIdSync: (original?: boolean) => string;
try {
@@ -13,10 +16,78 @@ try {
machineIdSync = () => "";
}
// #13679 PR B: checked-in literal, used ONLY as a last-resort fallback (see
// getActiveSalt() below) — /etc/machine-id is commonly world-readable, so relying on
// this literal as the real default let any local user derive the same bearer token.
const BUILTIN_DEFAULT_SALT = "omniroute-cli-auth-v1";
const SALT_FILE_NAME = "cli-token-salt.json";
const PERSISTED_SALT_RE = /^[0-9a-f]{64}$/;
function saltFilePath(dataDir: string): string {
return path.join(dataDir, SALT_FILE_NAME);
}
function readPersistedSalt(filePath: string): string | null {
try {
const raw = fs.readFileSync(filePath, "utf8");
const parsed: unknown = JSON.parse(raw);
const salt = (parsed as { salt?: unknown } | null)?.salt;
if (typeof salt === "string" && PERSISTED_SALT_RE.test(salt)) return salt;
} catch {
// Missing, unreadable, or corrupt — fall through to (re)generation.
}
return null;
}
/**
* Generate a random per-install salt on first use and persist it under DATA_DIR so
* every process on this install (the server and every `omniroute` CLI invocation,
* see the mirrored logic in bin/cli/utils/cliToken.mjs) converges on the same value.
* The write uses the `wx` flag (fails if the file already exists) so a race between
* two processes both hitting "no file yet" at once cannot clobber one another — the
* loser just reads back what the winner wrote instead of overwriting it.
*/
function establishPersistedSalt(dataDir: string): string | null {
const filePath = saltFilePath(dataDir);
const existing = readPersistedSalt(filePath);
if (existing) return existing;
const generated = randomBytes(32).toString("hex");
try {
fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(filePath, JSON.stringify({ salt: generated }), { flag: "wx", mode: 0o600 });
return generated;
} catch (err) {
if ((err as NodeJS.ErrnoException | null)?.code === "EEXIST") {
return readPersistedSalt(filePath);
}
return null;
}
}
let cachedActiveSalt: string | null = null;
function getActiveSalt(): string {
return process.env.OMNIROUTE_CLI_SALT || BUILTIN_DEFAULT_SALT;
const envSalt = process.env.OMNIROUTE_CLI_SALT;
if (envSalt) return envSalt;
if (cachedActiveSalt) return cachedActiveSalt;
const hasExplicitDataDir = !!process.env.DATA_DIR?.trim();
// A test process that never opted into an explicit DATA_DIR must not write a salt
// file into the operator's real home directory — mirrors the same class of guard
// resolveWritableDataDir() applies for the DB (dataPaths.ts::isTestContext). Falling
// back to the literal here only affects tests that forgot to set DATA_DIR; every
// production path (server boot, packaged CLI) always has one.
if (!hasExplicitDataDir && isTestContext()) {
cachedActiveSalt = BUILTIN_DEFAULT_SALT;
return cachedActiveSalt;
}
const dataDir = resolveDataDir();
const persisted = establishPersistedSalt(dataDir);
cachedActiveSalt = persisted ?? BUILTIN_DEFAULT_SALT;
return cachedActiveSalt;
}
export function deriveMachineToken(rawId: string, salt: string): string {

View File

@@ -0,0 +1,108 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
// #13679 PR B — src/lib/machineToken.ts::getActiveSalt() (mirrored in
// bin/cli/utils/cliToken.mjs) used to fall back to the checked-in literal default
// salt "omniroute-cli-auth-v1" whenever OMNIROUTE_CLI_SALT was unset. Since the CLI
// bearer token is HMAC-SHA256(raw machine-id, salt) and /etc/machine-id is commonly
// world-readable, every install that never set OMNIROUTE_CLI_SALT derived the SAME
// token from the same machine-id — any local user on that machine could compute it.
// The fix generates a random per-install salt on first use and persists it under
// DATA_DIR, so two installs on the same host (same underlying machine-id) diverge.
test("issue #13679 PR B: two fresh installs derive different CLI tokens from the same machine-id", async () => {
const previousCliSalt = process.env.OMNIROUTE_CLI_SALT;
const previousDataDir = process.env.DATA_DIR;
delete process.env.OMNIROUTE_CLI_SALT; // exercise the default-salt path, not the explicit override
const dataDirA = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-fresh-install-a-"));
const dataDirB = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-fresh-install-b-"));
try {
// Each `?fresh*=` query gives tsx/esm a distinct module cache key, so each
// "install" gets its own copy of machineToken.ts's module-level salt cache
// instead of reusing whatever the other install already resolved.
process.env.DATA_DIR = dataDirA;
const { getMachineTokenSync: getTokenA } = await import(
`../../src/lib/machineToken.ts?freshA=${Date.now()}-${Math.random()}`
);
const tokenA = getTokenA();
process.env.DATA_DIR = dataDirB;
const { getMachineTokenSync: getTokenB } = await import(
`../../src/lib/machineToken.ts?freshB=${Date.now()}-${Math.random()}`
);
const tokenB = getTokenB();
if (!tokenA || !tokenB) {
// node-machine-id unavailable on this host/platform — nothing to assert.
return;
}
assert.notEqual(
tokenA,
tokenB,
"two fresh installs on the same machine must not derive the same CLI bearer " +
"token from a shared checked-in default salt (fail-open on the literal default)"
);
const saltA = JSON.parse(
fs.readFileSync(path.join(dataDirA, "cli-token-salt.json"), "utf8")
).salt;
const saltB = JSON.parse(
fs.readFileSync(path.join(dataDirB, "cli-token-salt.json"), "utf8")
).salt;
assert.match(saltA, /^[0-9a-f]{64}$/, "persisted salt must be a random 64-char hex string");
assert.match(saltB, /^[0-9a-f]{64}$/, "persisted salt must be a random 64-char hex string");
assert.notEqual(saltA, saltB, "each install must generate its own random salt");
assert.notEqual(saltA, "omniroute-cli-auth-v1", "must not persist the checked-in literal");
} finally {
if (previousCliSalt === undefined) delete process.env.OMNIROUTE_CLI_SALT;
else process.env.OMNIROUTE_CLI_SALT = previousCliSalt;
if (previousDataDir === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = previousDataDir;
fs.rmSync(dataDirA, { recursive: true, force: true });
fs.rmSync(dataDirB, { recursive: true, force: true });
}
});
test("issue #13679 PR B: a second process on the same install reads back the persisted salt (byte-compatible)", async () => {
const previousCliSalt = process.env.OMNIROUTE_CLI_SALT;
const previousDataDir = process.env.DATA_DIR;
delete process.env.OMNIROUTE_CLI_SALT;
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-same-install-"));
process.env.DATA_DIR = dataDir;
try {
const { getMachineTokenSync: getTokenFirst } = await import(
`../../src/lib/machineToken.ts?sameA=${Date.now()}-${Math.random()}`
);
const first = getTokenFirst();
// A brand-new module instance (fresh module-level cache) simulates a second
// process (e.g. a `omniroute` CLI invocation) starting up against the same
// DATA_DIR after the salt file already exists.
const { getMachineTokenSync: getTokenSecond } = await import(
`../../src/lib/machineToken.ts?sameB=${Date.now()}-${Math.random()}`
);
const second = getTokenSecond();
if (!first || !second) return; // node-machine-id unavailable on this host
assert.equal(
first,
second,
"two processes reading the same persisted DATA_DIR salt must derive the same token"
);
} finally {
if (previousCliSalt === undefined) delete process.env.OMNIROUTE_CLI_SALT;
else process.env.OMNIROUTE_CLI_SALT = previousCliSalt;
if (previousDataDir === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = previousDataDir;
fs.rmSync(dataDir, { recursive: true, force: true });
}
});