diff --git a/src/app/api/cli-tools/claude-settings/route.js b/src/app/api/cli-tools/claude-settings/route.js index bb5fa06f..6a0e8291 100644 --- a/src/app/api/cli-tools/claude-settings/route.js +++ b/src/app/api/cli-tools/claude-settings/route.js @@ -6,6 +6,7 @@ import { promisify } from "util"; import fs from "fs/promises"; import path from "path"; import os from "os"; +import { redactSecrets } from "@/lib/security/redactSecrets"; const execAsync = promisify(exec); @@ -68,7 +69,7 @@ export async function GET() { return NextResponse.json({ installed: true, - settings: settings, + settings: redactSecrets(settings), has9Router: has9Router, settingsPath: getClaudeSettingsPath(), }); diff --git a/src/app/api/cli-tools/codex-settings/route.js b/src/app/api/cli-tools/codex-settings/route.js index ff20c575..c0f0c936 100644 --- a/src/app/api/cli-tools/codex-settings/route.js +++ b/src/app/api/cli-tools/codex-settings/route.js @@ -7,6 +7,7 @@ import fs from "fs/promises"; import path from "path"; import os from "os"; import { parseTOML, stringifyTOML } from "confbox"; +import { redactSecretsInText } from "@/lib/security/redactSecrets"; const execAsync = promisify(exec); @@ -96,7 +97,7 @@ export async function GET() { return NextResponse.json({ installed: true, - config, + config: redactSecretsInText(config), has9Router: has9RouterConfig(config), configPath: getCodexConfigPath(), }); diff --git a/src/app/api/cli-tools/copilot-settings/route.js b/src/app/api/cli-tools/copilot-settings/route.js index 3c0bd669..f2cf8b17 100644 --- a/src/app/api/cli-tools/copilot-settings/route.js +++ b/src/app/api/cli-tools/copilot-settings/route.js @@ -4,6 +4,7 @@ import { NextResponse } from "next/server"; import fs from "fs/promises"; import path from "path"; import os from "os"; +import { redactSecrets } from "@/lib/security/redactSecrets"; // Resolve chatLanguageModels.json path per OS const getConfigPath = () => { @@ -48,7 +49,7 @@ export async function GET() { return NextResponse.json({ installed: true, - config, + config: redactSecrets(config), has9Router: has9RouterConfig(config), configPath: getConfigPath(), currentModel: entry?.models?.[0]?.id || null, diff --git a/src/app/api/cli-tools/cowork-settings/route.js b/src/app/api/cli-tools/cowork-settings/route.js index d30b667e..8c9eb2f2 100644 --- a/src/app/api/cli-tools/cowork-settings/route.js +++ b/src/app/api/cli-tools/cowork-settings/route.js @@ -8,6 +8,7 @@ import crypto from "crypto"; import { DEFAULT_PLUGINS, LOCAL_STDIO_PLUGINS, buildManagedMcpServers } from "@/shared/constants/coworkPlugins"; import { UPDATER_CONFIG } from "@/shared/constants/config"; import { getConsistentMachineId } from "@/shared/utils/machineId"; +import { redactSecrets } from "@/lib/security/redactSecrets"; const APP_PORT = UPDATER_CONFIG.appPort; const CLI_TOKEN_HEADER = "x-9r-cli-token"; @@ -273,7 +274,7 @@ export async function GET() { return NextResponse.json({ installed: true, - config, + config: redactSecrets(config), has9Router, configPath, cowork: { diff --git a/src/app/api/cli-tools/deepseek-tui-settings/route.js b/src/app/api/cli-tools/deepseek-tui-settings/route.js index 0edf74da..871abce0 100644 --- a/src/app/api/cli-tools/deepseek-tui-settings/route.js +++ b/src/app/api/cli-tools/deepseek-tui-settings/route.js @@ -6,6 +6,7 @@ import { promisify } from "util"; import fs from "fs/promises"; import path from "path"; import os from "os"; +import { redactSecretsInText } from "@/lib/security/redactSecrets"; const execAsync = promisify(exec); @@ -112,7 +113,7 @@ export async function GET() { const config = parseToml(toml); return NextResponse.json({ installed: true, - settings: config, + settings: redactSecretsInText(config), has9Router: has9RouterConfig(config), configPath: getDeepSeekConfigPath(), }); diff --git a/src/app/api/cli-tools/droid-settings/route.js b/src/app/api/cli-tools/droid-settings/route.js index a4162578..d7140b31 100644 --- a/src/app/api/cli-tools/droid-settings/route.js +++ b/src/app/api/cli-tools/droid-settings/route.js @@ -6,6 +6,7 @@ import { promisify } from "util"; import fs from "fs/promises"; import path from "path"; import os from "os"; +import { redactSecrets } from "@/lib/security/redactSecrets"; const execAsync = promisify(exec); @@ -69,7 +70,7 @@ export async function GET() { return NextResponse.json({ installed: true, - settings, + settings: redactSecrets(settings), has9Router: has9RouterConfig(settings), settingsPath: getDroidSettingsPath(), }); diff --git a/src/app/api/cli-tools/jcode-settings/route.js b/src/app/api/cli-tools/jcode-settings/route.js index 9e05a161..c700e5b9 100644 --- a/src/app/api/cli-tools/jcode-settings/route.js +++ b/src/app/api/cli-tools/jcode-settings/route.js @@ -7,6 +7,7 @@ import os from "os"; import { exec } from "child_process"; import { promisify } from "util"; import { parseTOML, stringifyTOML } from "confbox"; +import { redactSecrets } from "@/lib/security/redactSecrets"; const execAsync = promisify(exec); @@ -122,7 +123,7 @@ export async function GET() { return NextResponse.json({ installed: true, - config, + config: redactSecrets(config), has9Router, configPath: getConfigPath(), }); diff --git a/src/app/api/cli-tools/openclaw-settings/route.js b/src/app/api/cli-tools/openclaw-settings/route.js index 2047a256..534cd92f 100644 --- a/src/app/api/cli-tools/openclaw-settings/route.js +++ b/src/app/api/cli-tools/openclaw-settings/route.js @@ -6,6 +6,7 @@ import { promisify } from "util"; import fs from "fs/promises"; import path from "path"; import os from "os"; +import { redactSecrets } from "@/lib/security/redactSecrets"; const execAsync = promisify(exec); @@ -103,8 +104,8 @@ export async function GET() { return NextResponse.json({ installed: true, - settings, - agents: enrichedAgents, + settings: redactSecrets(settings), + agents: redactSecrets(enrichedAgents), has9Router: has9RouterConfig(settings), settingsPath: getOpenClawSettingsPath(), }); diff --git a/src/app/api/cli-tools/opencode-settings/route.js b/src/app/api/cli-tools/opencode-settings/route.js index 03819c66..5456d671 100644 --- a/src/app/api/cli-tools/opencode-settings/route.js +++ b/src/app/api/cli-tools/opencode-settings/route.js @@ -6,6 +6,7 @@ import { promisify } from "util"; import fs from "fs/promises"; import path from "path"; import os from "os"; +import { redactSecrets } from "@/lib/security/redactSecrets"; const execAsync = promisify(exec); @@ -72,7 +73,7 @@ export async function GET() { return NextResponse.json({ installed: true, - config, + config: redactSecrets(config), has9Router: has9RouterConfig(config), configPath: getConfigPath(), opencode: { diff --git a/src/dashboardGuard.js b/src/dashboardGuard.js index 5af7f2ea..74487c16 100644 --- a/src/dashboardGuard.js +++ b/src/dashboardGuard.js @@ -45,12 +45,14 @@ const ALWAYS_PROTECTED = [ ]; // User administration is never exposed to normal users, even if dashboard login -// is disabled for local single-user deployments. -const ADMIN_ONLY_PATHS = ["/api/users", "/api/tunnel", "/api/headroom", "/api/pxpipe"]; +// is disabled for local single-user deployments. CLI Tools directly read and +// mutate the account running 9Router's local CLI configuration, so they are +// host administration rather than per-user dashboard preferences. +const ADMIN_ONLY_PATHS = ["/api/users", "/api/tunnel", "/api/headroom", "/api/pxpipe", "/api/cli-tools"]; // Dashboard paths requiring an administrator. Combo access is handled by its // owner-scoped API routes and is available to authenticated users. -const ADMIN_ONLY_DASHBOARD_PATHS = ["/dashboard/token-saver", "/dashboard/pxpipe"]; +const ADMIN_ONLY_DASHBOARD_PATHS = ["/dashboard/token-saver", "/dashboard/pxpipe", "/dashboard/cli-tools"]; // Require auth, but allow through if requireLogin is disabled const PROTECTED_API_PATHS = [ @@ -75,8 +77,6 @@ const PROTECTED_API_PATHS = [ // Routes that spawn child processes or read host secrets — restrict to localhost. const LOCAL_ONLY_PATHS = [ - "/api/cli-tools/cowork-settings", - "/api/cli-tools/antigravity-mitm", "/api/mcp/", "/api/tunnel/tailscale-install", "/api/tunnel/tailscale-enable", @@ -210,6 +210,19 @@ export const __test__ = { export async function proxy(request) { const { pathname } = request.nextUrl; + // CLI Tools access the server process's home directory and, in the MITM + // case, can change privileged system networking. Do not allow a shared CLI + // bearer token to become a remote host-administration credential: only a + // local, authenticated administrator may use these endpoints. + if (pathname === "/api/cli-tools" || pathname.startsWith("/api/cli-tools/")) { + if (!isLocalRequest(request)) { + return NextResponse.json({ error: "CLI Tools are available only from the local machine" }, { status: 403 }); + } + if (!(await isAdmin(request))) { + return NextResponse.json({ error: "Administrator access required" }, { status: 403 }); + } + } + // Local-only gate for spawn-capable / host-secret routes. if (LOCAL_ONLY_PATHS.some((p) => pathname.startsWith(p))) { if (!(await canAccessLocalOnlyRoute(request))) { diff --git a/src/lib/security/redactSecrets.js b/src/lib/security/redactSecrets.js new file mode 100644 index 00000000..5fab6fa2 --- /dev/null +++ b/src/lib/security/redactSecrets.js @@ -0,0 +1,30 @@ +const SECRET_KEY_PATTERN = /(?:api[_-]?key|auth[_-]?token|access[_-]?token|refresh[_-]?token|password|secret|credential|authorization|bearer|cookie|private[_-]?key)/i; +const REDACTED_VALUE = "[REDACTED]"; + +/** + * Return a deep copy suitable for an administrative status response. + * Configuration values are retained, but values stored under known credential + * keys are redacted before crossing the server-to-browser boundary. + */ +export function redactSecrets(value) { + if (Array.isArray(value)) return value.map(redactSecrets); + if (!value || typeof value !== "object") return value; + + return Object.fromEntries( + Object.entries(value).map(([key, child]) => [ + key, + SECRET_KEY_PATTERN.test(key) ? REDACTED_VALUE : redactSecrets(child), + ]), + ); +} + +/** Redact credential assignments embedded in text-based configuration formats. */ +export function redactSecretsInText(value) { + if (typeof value !== "string") return value; + + return value + .replace(/^(\s*(?:[A-Za-z0-9_.-]*?(?:api[_-]?key|auth[_-]?token|access[_-]?token|refresh[_-]?token|password|secret|credential|authorization|private[_-]?key)[A-Za-z0-9_.-]*)\s*[=:]\s*)([^\r\n#]+)/gim, `$1${REDACTED_VALUE}`) + .replace(/("(?:api[_-]?key|auth[_-]?token|access[_-]?token|refresh[_-]?token|password|secret|credential|authorization|private[_-]?key)"\s*:\s*")[^"]*(")/gim, `$1${REDACTED_VALUE}$2`); +} + +export { REDACTED_VALUE }; diff --git a/tests/unit/dashboard-guard.test.js b/tests/unit/dashboard-guard.test.js index b905baac..3fbcbb61 100644 --- a/tests/unit/dashboard-guard.test.js +++ b/tests/unit/dashboard-guard.test.js @@ -224,7 +224,7 @@ describe("dashboard guard local-only access", () => { expect(response.body.error).toBe("Local only: CLI token required"); }); - it("allows local-only route on loopback when requireLogin=false", async () => { + it("requires an administrator for CLI Tools even when dashboard login is disabled", async () => { mocks.getSettings.mockResolvedValue({ requireLogin: false }); const response = await proxy(request("/api/cli-tools/antigravity-mitm", { @@ -232,7 +232,8 @@ describe("dashboard guard local-only access", () => { origin: "http://localhost:20128", })); - expect(response).toBe(mocks.nextResponse); + expect(response.status).toBe(403); + expect(response.body.error).toBe("Administrator access required"); }); it("rejects local-only route from tunnel host even when requireLogin=false", async () => { @@ -266,7 +267,7 @@ describe("dashboard guard local-only access", () => { }); }); -describe("dashboard guard combo administration access", () => { +describe("dashboard guard CLI Tools administration access", () => { beforeEach(() => { vi.clearAllMocks(); mocks.getSettings.mockResolvedValue({ requireLogin: true }); @@ -277,28 +278,38 @@ describe("dashboard guard combo administration access", () => { mocks.verifyDashboardAuthToken.mockResolvedValue(true); }); - it("rejects normal users from every combos API operation", async () => { - for (const pathname of ["/api/combos", "/api/combos/combo-1"]) { - const response = await proxy(request(pathname, { host: "localhost:20128" }, "user-token")); + it("rejects normal users from host-level CLI Tools operations", async () => { + const response = await proxy(request("/api/cli-tools/claude-settings", { + host: "localhost:20128", + origin: "http://localhost:20128", + }, "user-token")); - expect(response.status).toBe(403); - expect(response.body.error).toBe("Administrator access required"); - } + expect(response.status).toBe(403); + expect(response.body.error).toBe("Administrator access required"); }); - it("redirects normal users away from the combos dashboard page", async () => { - const response = await proxy(request("/dashboard/combos", { host: "localhost:20128" }, "user-token")); - - expect(response.status).toBe(307); - expect(response.url).toBe("http://localhost/dashboard"); - }); - - it("allows administrators to access the combos page and API", async () => { + it("rejects remote CLI Tools access even with an administrator session", async () => { mocks.getUserById.mockResolvedValue({ id: "user-1", isActive: true, role: "admin" }); - expect(await proxy(request("/dashboard/combos", { host: "localhost:20128" }, "admin-token"))).toBe(mocks.nextResponse); - expect(await proxy(request("/api/combos", { host: "localhost:20128" }, "admin-token"))).toBe(mocks.nextResponse); + const response = await proxy(request("/api/cli-tools/claude-settings", { + host: "router.example.com", + }, "admin-token")); + + expect(response.status).toBe(403); + expect(response.body.error).toBe("CLI Tools are available only from the local machine"); }); + + it("allows a local administrator to use CLI Tools", async () => { + mocks.getUserById.mockResolvedValue({ id: "user-1", isActive: true, role: "admin" }); + + const response = await proxy(request("/api/cli-tools/claude-settings", { + host: "localhost:20128", + origin: "http://localhost:20128", + }, "admin-token")); + + expect(response).toBe(mocks.nextResponse); + }); + }); describe("dashboard guard token saver administration access", () => { diff --git a/tests/unit/redact-secrets.test.js b/tests/unit/redact-secrets.test.js new file mode 100644 index 00000000..ec42c85c --- /dev/null +++ b/tests/unit/redact-secrets.test.js @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { REDACTED_VALUE, redactSecrets, redactSecretsInText } from "../../src/lib/security/redactSecrets.js"; + +describe("CLI configuration secret redaction", () => { + it("redacts nested credential fields without mutating other settings", () => { + const input = { + endpoint: "http://localhost:20128/v1", + env: { + ANTHROPIC_AUTH_TOKEN: "sk-sensitive", + CUSTOM_SETTING: "preserved", + }, + providers: [{ apiKey: "another-secret", baseURL: "https://api.example.com" }], + }; + + expect(redactSecrets(input)).toEqual({ + endpoint: "http://localhost:20128/v1", + env: { + ANTHROPIC_AUTH_TOKEN: REDACTED_VALUE, + CUSTOM_SETTING: "preserved", + }, + providers: [{ apiKey: REDACTED_VALUE, baseURL: "https://api.example.com" }], + }); + expect(input.env.ANTHROPIC_AUTH_TOKEN).toBe("sk-sensitive"); + }); + + it("redacts TOML, YAML, dotenv, and JSON credential assignments", () => { + const config = [ + 'api_key = "sk-sensitive"', + "ANTHROPIC_AUTH_TOKEN=token-value", + '"refresh_token": "refresh-value",', + "base_url = \"http://localhost:20128/v1\"", + ].join("\n"); + + const result = redactSecretsInText(config); + + expect(result).not.toContain("sk-sensitive"); + expect(result).not.toContain("token-value"); + expect(result).not.toContain("refresh-value"); + expect(result).toContain(`api_key = ${REDACTED_VALUE}`); + expect(result).toContain(`ANTHROPIC_AUTH_TOKEN=${REDACTED_VALUE}`); + expect(result).toContain(`"refresh_token": "${REDACTED_VALUE}"`); + expect(result).toContain('base_url = "http://localhost:20128/v1"'); + }); +});