diff --git a/bin/cli/utils/cliToken.mjs b/bin/cli/utils/cliToken.mjs index da504019a3..43712f0644 100644 --- a/bin/cli/utils/cliToken.mjs +++ b/bin/cli/utils/cliToken.mjs @@ -1,22 +1,40 @@ import crypto from "node:crypto"; -const SALT = "omniroute-cli-auth-v1"; +const BUILTIN_DEFAULT_SALT = "omniroute-cli-auth-v1"; export const CLI_TOKEN_HEADER = "x-omniroute-cli-token"; let _cached = null; +let _cachedSalt = 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; +} export async function getCliToken() { - if (_cached !== null) return _cached; + const salt = getActiveSalt(); + if (_cached !== null && _cachedSalt === salt) return _cached; try { - const { machineIdSync } = await import("node-machine-id"); + // 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 mod = await import("node-machine-id"); + const machineIdSync = mod.machineIdSync ?? mod.default?.machineIdSync; const mid = machineIdSync(); _cached = crypto .createHash("sha256") - .update(mid + SALT) + .update(mid + salt) .digest("hex") .substring(0, 32); - } catch { + } catch (e) { + // 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:", e); _cached = ""; } + _cachedSalt = salt; return _cached; } diff --git a/changelog.d/fixes/10612-cli-token-machine-id-interop.md b/changelog.d/fixes/10612-cli-token-machine-id-interop.md new file mode 100644 index 0000000000..48ec5b8e1d --- /dev/null +++ b/changelog.d/fixes/10612-cli-token-machine-id-interop.md @@ -0,0 +1 @@ +- **fix(cli):** derive the machine-id token correctly under plain Node — `await import("node-machine-id")` puts the CJS exports on `.default`, so the destructured `machineIdSync` was `undefined` and the catch blanked the token, sending every management request unauthenticated; `OMNIROUTE_CLI_SALT` rotation is now honored too ([#10612](https://github.com/diegosouzapw/OmniRoute/pull/10612)) diff --git a/tests/unit/cli-machine-token.test.ts b/tests/unit/cli-machine-token.test.ts index 8b8d6f762a..da96cbba2e 100644 --- a/tests/unit/cli-machine-token.test.ts +++ b/tests/unit/cli-machine-token.test.ts @@ -1,6 +1,9 @@ import test from "node:test"; import assert from "node:assert/strict"; import crypto from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; test("cliToken.mjs pode ser importado sem erro", async () => { const mod = await import("../../bin/cli/utils/cliToken.mjs"); @@ -9,12 +12,28 @@ test("cliToken.mjs pode ser importado sem erro", async () => { assert.equal(mod.CLI_TOKEN_HEADER, "x-omniroute-cli-token"); }); -test("getCliToken retorna string de 32 chars ou string vazia", async () => { - const { getCliToken } = await import("../../bin/cli/utils/cliToken.mjs"); - const token = await getCliToken(); - assert.ok(typeof token === "string"); - // Pode ser "" se node-machine-id falhar, ou 32 chars se funcionar. - assert.ok(token === "" || token.length === 32, `expected 0 or 32 chars, got ${token.length}`); +test("getCliToken deriva token de 32 chars sob o node puro que a CLI usa", async () => { + const mod = await import("node-machine-id"); + const machineIdSync = mod.machineIdSync ?? mod.default?.machineIdSync; + // Sem machine-id nesta plataforma não há token a derivar — nada a afirmar. + if (typeof machineIdSync !== "function") return; + + // Precisa rodar em `node` puro, sem o loader tsx/esm: o tsx resolve os named + // exports de um CJS e mascara o bug de interop. `omniroute` roda sob node puro, + // onde `const { machineIdSync } = await import(...)` dava undefined, o catch + // zerava o token e TODA requisição de management saía sem autenticação. + const repoRoot = fileURLToPath(new URL("../..", import.meta.url)); + const entry = pathToFileURL(join(repoRoot, "bin/cli/utils/cliToken.mjs")).href; + const out = execFileSync( + process.execPath, + [ + "-e", + `import(${JSON.stringify(entry)}).then(m => m.getCliToken()).then(t => console.log(t.length))`, + ], + { cwd: repoRoot, encoding: "utf8" } + ); + + assert.equal(out.trim(), "32", `expected a derived 32-char token, got length ${out.trim()}`); }); test("getCliToken retorna mesmo valor em chamadas repetidas (cache)", async () => { @@ -24,6 +43,28 @@ test("getCliToken retorna mesmo valor em chamadas repetidas (cache)", async () = assert.equal(t1, t2); }); +test("getCliToken respeita rotação de OMNIROUTE_CLI_SALT", async () => { + const mod = await import("node-machine-id"); + const machineIdSync = mod.machineIdSync ?? mod.default?.machineIdSync; + if (typeof machineIdSync !== "function") return; + + const { getCliToken } = await import("../../bin/cli/utils/cliToken.mjs"); + const original = process.env.OMNIROUTE_CLI_SALT; + try { + delete process.env.OMNIROUTE_CLI_SALT; + const withDefaultSalt = await getCliToken(); + process.env.OMNIROUTE_CLI_SALT = "rotated-salt-for-test"; + const withRotatedSalt = await getCliToken(); + // docs/security/CLI_TOKEN.md promete que a rotação alcança os processos CLI; + // o SALT hardcoded ignorava a env var e devolvia sempre o mesmo token. + assert.notEqual(withRotatedSalt, withDefaultSalt); + assert.equal(withRotatedSalt.length, 32); + } finally { + if (original === undefined) delete process.env.OMNIROUTE_CLI_SALT; + else process.env.OMNIROUTE_CLI_SALT = original; + } +}); + test("getCliToken produz apenas hex lowercase se não-vazio", async () => { const { getCliToken } = await import("../../bin/cli/utils/cliToken.mjs"); const token = await getCliToken();