Files
OmniRoute/tests/unit/authz/spawn-capable-prefixes-client-safe.test.ts
Diego Rodrigues de Sa e Souza c8b24ffc30 fix(authz): gate the cli-tools status and skills execution routes to LOCAL_ONLY (#13745)
GHSA-35fw-cv32-2373 and GHSA-jx89-f37j-pq89 — the same defect class as
/api/acp/agents (GHSA-hf57): a route whose handler chain spawns a host process
was classified Tier 3 MANAGEMENT only, and requireManagementAuth() waives auth
when requireLogin=false. Hard Rules #15/#17 require the LOCAL_ONLY gate, which
runs on the stamped real peer before any auth check.

cli-tools (GHSA-35fw): 14 routes reach
getCliRuntimeStatus() -> locateCommand() -> runProcess("sh", ["-c",
'command -v -- "$1"']) -> spawn(), exactly like their six gated siblings
(forge/grok-build/jcode/qwen/omp/letta-settings):
all-statuses, status, and the claude/cline/codewhale/codex/crush/deepseek-tui/
droid/kilo/openclaw/pi/smelt-settings routes. The advisory counted 13; it
missed /api/cli-tools/detect, which is heavier — detectAllTools() runs
execFile(binary, ["--version"]) and execFile("which") per tool.

skills (GHSA-jx89): POST /api/skills/install stores the request's handlerCode
verbatim as the skill handler with no allowlist, so a value equal to a built-in
name (execute_command / eval_code) aliases the real sandboxed built-in;
POST /api/skills/executions then runs it. The sandbox is a real container, but
the spawn is transitive, which is why the 6A.8 source scan never flagged it.

Entries are exact paths, not a /api/cli-tools/ blanket prefix: apply, backups,
config, guide-settings, hermes-agent-settings, keys, logs, openclaw/auto-order
and codex-profiles do not spawn and remote dashboards use them. All 16 are
mirrored into SPAWN_CAPABLE_PREFIXES (no manage-scope bypass) and added to the
route-guard-membership roots so the gate enforces them from now on.

Functional trade-off, same one already accepted for grok/forge/jcode/qwen: a
dashboard served through a tunnel no longer shows the CLI Tools status badges.

Tests are red-first. Two existing negative controls pointed at routes that turn
out to spawn (/api/cli-tools/all-statuses, /api/skills/install); they now point
at routes that genuinely do not (/api/cli-tools/config, /api/skills/marketplace,
/api/skills/skillssh/install), so the non-over-gating assertions are kept.
2026-09-15 13:25:35 -03:00

123 lines
5.1 KiB
TypeScript

import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { SPAWN_CAPABLE_PREFIXES } from "@/shared/constants/spawnCapablePrefixes";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
const VALIDATION_DIR = join(ROOT, "src/shared/validation");
function walkTsFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) out.push(...walkTsFiles(full));
else if (/\.tsx?$/.test(entry) && !/\.test\.tsx?$/.test(entry)) out.push(full);
}
return out;
}
/**
* Returns the VALUE-import specifiers in `src` (i.e. imports that survive to the
* runtime bundle). `import type …` is excluded — it is erased by the compiler/SWC and
* never creates a webpack bundle edge, so it cannot leak a Node-only dep into a
* client bundle.
*/
function valueImportSpecifiers(src: string): string[] {
const specs: string[] = [];
// import [type] [<clause> from] "<spec>" — `[^"';]*?` spans multi-line clauses.
const re = /\bimport\s+(type\s+)?(?:[^"';]*?\bfrom\s*)?["']([^"']+)["']/g;
let m: RegExpExecArray | null;
while ((m = re.exec(src))) {
if (m[1]) continue; // `import type …` — erased, not a runtime edge
specs.push(m[2]);
}
return specs;
}
// Regression guard for the dast-smoke "Build CLI bundle" failure:
// Module not found: Can't resolve 'dns' / 'net' (./node_modules/ioredis/...)
// Root cause: `settingsSchemas.ts` VALUE-imported SPAWN_CAPABLE_PREFIXES from
// `@/server/authz/routeGuard`, whose server runtime (runtimeSettings → localDb →
// apiKeys → rateLimiter → ioredis) then got dragged into the client/CLI webpack bundle
// via the dashboard onboarding wizard → validation barrel chain. Validation schemas are
// client-reachable and MUST depend only on zod + `@/shared` leaves — never on the
// server (`@/server/…`) or server-side lib (`@/lib/…`, which reaches the DB/ioredis).
const FORBIDDEN_VALUE_ROOTS = ["@/server/", "@/lib/"];
test("validation schemas must not VALUE-import from server-side roots (client/CLI build safety)", () => {
const offenders: string[] = [];
for (const file of walkTsFiles(VALIDATION_DIR)) {
const rel = file.slice(ROOT.length + 1);
for (const spec of valueImportSpecifiers(readFileSync(file, "utf8"))) {
if (FORBIDDEN_VALUE_ROOTS.some((root) => spec.startsWith(root))) {
offenders.push(`${rel}${spec}`);
}
}
}
assert.deepEqual(
offenders,
[],
"Client-reachable validation schemas VALUE-import server-side modules, which drags the " +
"server runtime (→ ioredis) into the browser/CLI bundle and breaks the Next build with " +
`"Can't resolve 'dns'/'net'". Move the needed value to a server-free @/shared/constants ` +
`leaf (see src/shared/constants/spawnCapablePrefixes.ts). Offenders:\n ${offenders.join("\n ")}`
);
});
test("SPAWN_CAPABLE_PREFIXES is defined in the server-free constants leaf with the expected entries", () => {
assert.ok(Array.isArray(SPAWN_CAPABLE_PREFIXES));
// The full deny-list survived the extraction out of routeGuard.ts (Hard Rules #15/#17).
for (const prefix of [
"/api/cli-tools/runtime/",
"/api/services/",
"/api/tools/agent-bridge/",
"/api/tools/traffic-inspector/",
"/api/plugins/",
"/api/local/",
"/api/skills/collect/",
"/api/headroom/start",
"/api/headroom/stop",
"/api/vnc-session",
"/api/modality-bridge/video/",
"/api/settings/mitm",
"/api/cli-tools/antigravity-mitm",
"/api/tunnels/cloudflared",
"/api/tunnels/tailscale/disable",
"/api/tunnels/tailscale/enable",
"/api/tunnels/tailscale/install",
"/api/tunnels/tailscale/login",
"/api/tunnels/tailscale/start-daemon",
// GHSA-35fw-cv32-2373: the 14 cli-tools routes that reach the same
// getCliRuntimeStatus() / detectAllTools() spawn as their gated siblings.
"/api/cli-tools/all-statuses",
"/api/cli-tools/claude-settings",
"/api/cli-tools/cline-settings",
"/api/cli-tools/codewhale-settings",
"/api/cli-tools/codex-settings",
"/api/cli-tools/crush-settings",
"/api/cli-tools/deepseek-tui-settings",
"/api/cli-tools/detect",
"/api/cli-tools/droid-settings",
"/api/cli-tools/kilo-settings",
"/api/cli-tools/openclaw-settings",
"/api/cli-tools/pi-settings",
"/api/cli-tools/smelt-settings",
"/api/cli-tools/status",
// GHSA-jx89-f37j-pq89: skills handler registration + execution reach the
// sandbox container spawn transitively.
"/api/skills/install",
"/api/skills/executions",
]) {
assert.ok(
SPAWN_CAPABLE_PREFIXES.includes(prefix),
`SPAWN_CAPABLE_PREFIXES lost the spawn-capable prefix "${prefix}" during extraction`
);
}
// 20 at extraction time + 14 (GHSA-35fw-cv32-2373) + 2 (GHSA-jx89-f37j-pq89).
// qwen-settings is the one pre-existing entry not enumerated above.
assert.equal(SPAWN_CAPABLE_PREFIXES.length, 36);
});