mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-18 12:52:25 +03:00
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
124 lines
4.6 KiB
JavaScript
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;
|
|
}
|