From 79438ef39187bcd556b779ece1df71ee33d77e49 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Fri, 15 May 2026 04:44:07 -0300 Subject: [PATCH] =?UTF-8?q?feat(cli):=208.12/8.14=20=E2=80=94=20testes=20s?= =?UTF-8?q?erver-side=20e=20doc=20CLI=5FTOKEN=5FAUTH?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adiciona testes de isLoopback (aceita loopback, rejeita IPs públicos), verificação de hash por máquina e DISABLE flag; testes de detectRestrictedEnvironment para Codespaces/WSL/CI/Gitpod; e docs/security/CLI_TOKEN_AUTH.md com threat model. --- docs/security/CLI_TOKEN_AUTH.md | 43 +++++++++++ src/lib/middleware/cliTokenAuth.ts | 2 +- tests/unit/cli-environment.test.ts | 104 +++++++++++++++++++++++++++ tests/unit/cli-machine-token.test.ts | 49 +++++++++++++ 4 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 docs/security/CLI_TOKEN_AUTH.md create mode 100644 tests/unit/cli-environment.test.ts diff --git a/docs/security/CLI_TOKEN_AUTH.md b/docs/security/CLI_TOKEN_AUTH.md new file mode 100644 index 0000000000..7cf747bb22 --- /dev/null +++ b/docs/security/CLI_TOKEN_AUTH.md @@ -0,0 +1,43 @@ +# CLI Machine-ID Token Authentication + +OmniRoute's CLI uses a **machine-derived token** to authenticate to the local server without requiring an explicit API key. This enables zero-config local use while preserving security for remote access. + +## How it works + +1. **CLI side** (`bin/cli/utils/cliToken.mjs`): computes `SHA-256(machineId + salt).hex[0..32]` using [`node-machine-id`](https://github.com/automation-stack/node-machine-id) and injects the result as the `x-omniroute-cli-token` header on every `apiFetch` call. + +2. **Server side** (`src/lib/middleware/cliTokenAuth.ts`): `isCliTokenAuthValid(request)` accepts the token only if: + - `OMNIROUTE_DISABLE_CLI_TOKEN` is not `"true"` + - The header is present and exactly 32 hex characters + - The originating IP is loopback (`127.0.0.1`, `::1`, `::ffff:127.0.0.1`) + - The token matches the server's own machine-derived hash (timing-safe compare) + +3. `requireManagementAuth` and other route guards call `isCliTokenAuthValid` before checking API keys — so the CLI gets transparent localhost access without storing any credential. + +## Threat model + +| Scenario | Risk | Mitigation | +| ------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| Another user on same host | Could compute the same token | `machine-id` is per-device; on single-user desktops this is acceptable. Use `OMNIROUTE_DISABLE_CLI_TOKEN=true` in multi-user setups. | +| Token leak via logs | Logs may reveal the token | The header value is masked in audit logs (`x-omniroute-cli-token: ***`). | +| Replay attack | Token is static | Only accepted from `127.0.0.1`/`::1`. Rejected for any other `x-forwarded-for` IP. | +| Reuse on another machine | Machine-bound by design | `node-machine-id` reads `/etc/machine-id` (Linux), `IOPlatformUUID` (macOS), `MachineGuid` (Windows). Different per host. | + +## Opt-out + +Set `OMNIROUTE_DISABLE_CLI_TOKEN=true` in `.env` or the server environment to disable this mechanism entirely. All access then requires an explicit API key. + +## Audit logging + +Every request authenticated via CLI token is logged with `event: "cli_token_auth"`, the source IP, user-agent, path, and the first 8 characters of the machine-id hash (non-reversible). + +## API key precedence + +An explicit `Authorization: Bearer ` header (from `--api-key` or `OMNIROUTE_API_KEY`) always takes precedence over the CLI token and is evaluated first. + +## Related files + +- `bin/cli/utils/cliToken.mjs` — CLI token generation +- `src/lib/middleware/cliTokenAuth.ts` — server validation +- `src/lib/api/requireManagementAuth.ts` — integration into auth pipeline +- `tests/unit/cli-machine-token.test.ts` — unit tests diff --git a/src/lib/middleware/cliTokenAuth.ts b/src/lib/middleware/cliTokenAuth.ts index bd97e69891..7d404239df 100644 --- a/src/lib/middleware/cliTokenAuth.ts +++ b/src/lib/middleware/cliTokenAuth.ts @@ -4,7 +4,7 @@ import { headers } from "next/headers"; const SALT = "omniroute-cli-auth-v1"; const HEADER_NAME = "x-omniroute-cli-token"; -function isLoopback(ip: string): boolean { +export function isLoopback(ip: string): boolean { const normalized = ip.replace(/^::ffff:/, ""); return normalized === "127.0.0.1" || normalized === "::1" || normalized === "localhost"; } diff --git a/tests/unit/cli-environment.test.ts b/tests/unit/cli-environment.test.ts new file mode 100644 index 0000000000..2603fa8823 --- /dev/null +++ b/tests/unit/cli-environment.test.ts @@ -0,0 +1,104 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +test("environment.mjs pode ser importado sem erro", async () => { + const mod = await import("../../bin/cli/utils/environment.mjs"); + assert.equal(typeof mod.detectRestrictedEnvironment, "function"); + assert.equal(typeof mod.getEnvBanner, "function"); +}); + +test("detectRestrictedEnvironment retorna objeto com type e flags", async () => { + const { detectRestrictedEnvironment } = await import("../../bin/cli/utils/environment.mjs"); + const result = detectRestrictedEnvironment(); + assert.ok(typeof result === "object" && result !== null); + assert.ok(typeof result.type === "string"); + assert.ok(typeof result.canOpenBrowser === "boolean"); + assert.ok(typeof result.canUseTray === "boolean"); +}); + +test("detectRestrictedEnvironment detecta Codespaces via CODESPACES=true", async () => { + const { detectRestrictedEnvironment } = await import("../../bin/cli/utils/environment.mjs"); + const orig = process.env.CODESPACES; + process.env.CODESPACES = "true"; + try { + const result = detectRestrictedEnvironment(); + assert.equal(result.type, "github-codespaces"); + assert.equal(result.canOpenBrowser, false); + assert.equal(result.canUseTray, false); + } finally { + if (orig === undefined) delete process.env.CODESPACES; + else process.env.CODESPACES = orig; + } +}); + +test("detectRestrictedEnvironment detecta WSL via WSL_DISTRO_NAME", async () => { + const { detectRestrictedEnvironment } = await import("../../bin/cli/utils/environment.mjs"); + // Skip se Codespaces (env pode estar setado no CI) + if (process.env.CODESPACES === "true") return; + const orig = process.env.WSL_DISTRO_NAME; + process.env.WSL_DISTRO_NAME = "Ubuntu"; + try { + const result = detectRestrictedEnvironment(); + assert.equal(result.type, "wsl"); + assert.equal(result.canOpenBrowser, true); + assert.equal(result.canUseTray, false); + } finally { + if (orig === undefined) delete process.env.WSL_DISTRO_NAME; + else process.env.WSL_DISTRO_NAME = orig; + } +}); + +test("detectRestrictedEnvironment detecta CI via CI env var", async () => { + const { detectRestrictedEnvironment } = await import("../../bin/cli/utils/environment.mjs"); + if (process.env.CODESPACES === "true" || process.env.WSL_DISTRO_NAME) return; + const orig = process.env.CI; + process.env.CI = "true"; + try { + const result = detectRestrictedEnvironment(); + assert.equal(result.type, "ci"); + assert.equal(result.canOpenBrowser, false); + } finally { + if (orig === undefined) delete process.env.CI; + else process.env.CI = orig; + } +}); + +test("detectRestrictedEnvironment detecta Gitpod via GITPOD_WORKSPACE_ID", async () => { + const { detectRestrictedEnvironment } = await import("../../bin/cli/utils/environment.mjs"); + if (process.env.CODESPACES === "true" || process.env.WSL_DISTRO_NAME) return; + const orig = process.env.GITPOD_WORKSPACE_ID; + process.env.GITPOD_WORKSPACE_ID = "ws-abc123"; + try { + const result = detectRestrictedEnvironment(); + assert.equal(result.type, "gitpod"); + assert.equal(result.canOpenBrowser, false); + assert.equal(result.canUseTray, false); + } finally { + if (orig === undefined) delete process.env.GITPOD_WORKSPACE_ID; + else process.env.GITPOD_WORKSPACE_ID = orig; + } +}); + +test("getEnvBanner retorna null em ambiente desktop", async () => { + const { getEnvBanner, detectRestrictedEnvironment } = + await import("../../bin/cli/utils/environment.mjs"); + const env = detectRestrictedEnvironment(); + if (env.type !== "desktop") return; // não pode testar desktop se rodando em CI/Codespaces + const banner = getEnvBanner(); + assert.equal(banner, null); +}); + +test("getEnvBanner retorna string em ambiente restrito (Gitpod)", async () => { + const { getEnvBanner } = await import("../../bin/cli/utils/environment.mjs"); + if (process.env.CODESPACES === "true" || process.env.WSL_DISTRO_NAME) return; + const orig = process.env.GITPOD_WORKSPACE_ID; + process.env.GITPOD_WORKSPACE_ID = "ws-test"; + try { + const banner = getEnvBanner(); + assert.ok(typeof banner === "string"); + assert.ok((banner as string).includes("gitpod")); + } finally { + if (orig === undefined) delete process.env.GITPOD_WORKSPACE_ID; + else process.env.GITPOD_WORKSPACE_ID = orig; + } +}); diff --git a/tests/unit/cli-machine-token.test.ts b/tests/unit/cli-machine-token.test.ts index 9b206c1c85..631c71df08 100644 --- a/tests/unit/cli-machine-token.test.ts +++ b/tests/unit/cli-machine-token.test.ts @@ -1,5 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; +import crypto from "node:crypto"; test("cliToken.mjs pode ser importado sem erro", async () => { const mod = await import("../../bin/cli/utils/cliToken.mjs"); @@ -42,3 +43,51 @@ test("OMNIROUTE_CLI_TOKEN env sobrescreve token gerado em apiFetch", async () => else process.env.OMNIROUTE_CLI_TOKEN = orig; } }); + +// --- testes server-side: isLoopback --- + +test("isLoopback aceita 127.0.0.1", async () => { + const { isLoopback } = await import("../../src/lib/middleware/cliTokenAuth"); + assert.ok(isLoopback("127.0.0.1")); +}); + +test("isLoopback aceita ::1", async () => { + const { isLoopback } = await import("../../src/lib/middleware/cliTokenAuth"); + assert.ok(isLoopback("::1")); +}); + +test("isLoopback aceita ::ffff:127.0.0.1 (IPv4-mapped)", async () => { + const { isLoopback } = await import("../../src/lib/middleware/cliTokenAuth"); + assert.ok(isLoopback("::ffff:127.0.0.1")); +}); + +test("isLoopback rejeita IP público", async () => { + const { isLoopback } = await import("../../src/lib/middleware/cliTokenAuth"); + assert.ok(!isLoopback("192.168.1.100")); + assert.ok(!isLoopback("10.0.0.1")); + assert.ok(!isLoopback("8.8.8.8")); +}); + +test("token derivado de machine-id diferente produz hash diferente", () => { + const SALT = "omniroute-cli-auth-v1"; + const hash = (mid: string) => + crypto + .createHash("sha256") + .update(mid + SALT) + .digest("hex") + .substring(0, 32); + const t1 = hash("machine-id-host-A"); + const t2 = hash("machine-id-host-B"); + assert.notEqual(t1, t2); + assert.match(t1, /^[0-9a-f]{32}$/); + assert.match(t2, /^[0-9a-f]{32}$/); +}); + +test("OMNIROUTE_DISABLE_CLI_TOKEN desabilita auth (estrutura verificada)", async () => { + const { readFileSync } = await import("node:fs"); + const { join, dirname } = await import("node:path"); + const { fileURLToPath } = await import("node:url"); + const dir = dirname(fileURLToPath(import.meta.url)); + const src = readFileSync(join(dir, "../../src/lib/middleware/cliTokenAuth.ts"), "utf8"); + assert.ok(src.includes("OMNIROUTE_DISABLE_CLI_TOKEN")); +});