mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(cli): 8.12/8.14 — testes server-side e doc CLI_TOKEN_AUTH
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.
This commit is contained in:
43
docs/security/CLI_TOKEN_AUTH.md
Normal file
43
docs/security/CLI_TOKEN_AUTH.md
Normal file
@@ -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 <key>` 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
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
104
tests/unit/cli-environment.test.ts
Normal file
104
tests/unit/cli-environment.test.ts
Normal file
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user