mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-21 06:12:17 +03:00
Compare commits
1 Commits
feat/antig
...
fix/13679b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b322d410b |
@@ -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).
|
||||
|
||||
1
changelog.d/fixes/13679-cli-token-random-salt.md
Normal file
1
changelog.d/fixes/13679-cli-token-random-salt.md
Normal 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)
|
||||
@@ -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. |
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
@@ -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 {
|
||||
|
||||
108
tests/unit/machine-token-random-salt-13679.test.ts
Normal file
108
tests/unit/machine-token-random-salt-13679.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user