mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 05:31:47 +00:00
fix: cli tool page to update the config correctly
This commit is contained in:
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
+18
-5
@@ -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))) {
|
||||
|
||||
@@ -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 };
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user