Files
OmniRoute/bin/cli/utils/cliToken.mjs
Diego Rodrigues de Sa e Souza 21772f40f3 fix(security): generate a random per-install CLI token salt (#13679) (#13909)
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-17 10:45:43 -03:00

124 lines
4.6 KiB
JavaScript

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() {
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) {
try {
// node-machine-id is CommonJS: under `await import()` its exports land on
// `.default`, so destructuring `machineIdSync` off the namespace yields
// 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;
if (typeof machineIdSync !== "function") return "";
// machineIdSync(true) returns the original unhashed hardware ID — mirrors
// getMachineTokenSync() in src/lib/machineToken.ts (#10148 cliToken hardening).
const rawId = machineIdSync(true);
if (!rawId) return "";
return crypto.createHmac("sha256", rawId).update(salt).digest("hex");
} catch {
return "";
}
}
export async function getCliToken() {
const salt = getActiveSalt();
if (_cached !== null && _cachedSalt === salt) return _cached;
try {
const imported = await import("node-machine-id");
const token = deriveCliToken(imported, salt);
if (!token) {
// Swallowing here changes control flow (every management call goes out
// unauthenticated and 401s), so leave a breadcrumb rather than failing mute.
console.debug("[CLI_TOKEN] machine-id resolution failed, CLI auth disabled");
}
_cached = token;
} catch (e) {
console.debug("[CLI_TOKEN] machine-id resolution failed, CLI auth disabled:", e);
_cached = "";
}
_cachedSalt = salt;
return _cached;
}