diff --git a/changelog.d/fixes/tunnel-process-routes-local-only.md b/changelog.d/fixes/tunnel-process-routes-local-only.md new file mode 100644 index 0000000000..b8ee3071b8 --- /dev/null +++ b/changelog.d/fixes/tunnel-process-routes-local-only.md @@ -0,0 +1 @@ +- Classify Cloudflared and Tailscale process-management routes as local-only and spawn-capable while preserving remote access to read-only tunnel status endpoints. diff --git a/src/server/authz/routeGuard.ts b/src/server/authz/routeGuard.ts index cfb7fcd08f..942fe6d8fe 100644 --- a/src/server/authz/routeGuard.ts +++ b/src/server/authz/routeGuard.ts @@ -40,6 +40,12 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray = [ "/api/cli-tools/jcode-settings", // spawns via getCliRuntimeStatus() to detect the `jcode` CLI install (Hard Rules #15 + #17, #7263) "/api/cli-tools/qwen-settings", // GET probes the local `qwen` binary; writes target ~/.qwen config files (Hard Rules #15 + #17) "/api/services/", // T-10: embedded service lifecycle (spawn child processes) + "/api/tunnels/cloudflared", // POST installs/starts/stops cloudflared; safe methods are exempted below + "/api/tunnels/tailscale/disable", // stops Funnel and may stop tailscaled/Tailscale service + "/api/tunnels/tailscale/enable", // starts tailscaled/login/funnel subprocesses + "/api/tunnels/tailscale/install", // downloads/installs Tailscale and starts its daemon + "/api/tunnels/tailscale/login", // spawns `tailscale up` + "/api/tunnels/tailscale/start-daemon", // starts tailscaled/Tailscale service "/dashboard/providers/services/", // T-07: reverse proxy to embedded service UIs "/api/copilot/", // unauthenticated LLM driver — CLI-only by default; admins can opt-in to remote access via manage-scope bypass "/api/tools/agent-bridge/", // AgentBridge: spawns MITM server + DNS edits (Hard Rules #15 + #17) @@ -213,7 +219,10 @@ export function isPrivateLanHost(hostHeader: string | null): boolean { * triggers the auto-update flow (spawns git checkout + npm install + pm2). * Hard Rules #15/#17 still apply to POST. */ -export const LOCAL_ONLY_API_GET_EXEMPTIONS: ReadonlySet = new Set(["/api/system/version"]); +export const LOCAL_ONLY_API_GET_EXEMPTIONS: ReadonlySet = new Set([ + "/api/system/version", + "/api/tunnels/cloudflared", +]); /** Safe HTTP methods that can be exempted for read-only paths. */ const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); diff --git a/src/shared/constants/spawnCapablePrefixes.ts b/src/shared/constants/spawnCapablePrefixes.ts index 92d74812e5..d6392b202b 100644 --- a/src/shared/constants/spawnCapablePrefixes.ts +++ b/src/shared/constants/spawnCapablePrefixes.ts @@ -27,6 +27,12 @@ export const SPAWN_CAPABLE_PREFIXES: ReadonlyArray = [ "/api/cli-tools/runtime/", "/api/cli-tools/qwen-settings", // GET probes the Qwen Code binary; the route also mutates local ~/.qwen files "/api/services/", // T-10: can run npm install + spawn node processes + "/api/tunnels/cloudflared", // POST installs/starts/stops cloudflared; safe methods remain read-only exempt + "/api/tunnels/tailscale/disable", // stops Funnel and may stop tailscaled/Tailscale service + "/api/tunnels/tailscale/enable", // starts tailscaled/login/funnel subprocesses + "/api/tunnels/tailscale/install", // downloads/installs Tailscale and starts its daemon + "/api/tunnels/tailscale/login", // spawns `tailscale up` + "/api/tunnels/tailscale/start-daemon", // starts tailscaled/Tailscale service "/api/tools/agent-bridge/", // start/stop MITM server + DNS edits (Hard Rules #15 + #17) "/api/settings/mitm", // installs a system trusted root CA + /etc/hosts DNS overrides via src/mitm/* — must never be whitelistable via manage-scope bypass (GHSA-x7vm-hp44-9p79, Hard Rules #15 + #17) "/api/cli-tools/antigravity-mitm", // same privileged CA-trust + DNS surface as /api/settings/mitm (GHSA-x7vm-hp44-9p79, Hard Rules #15 + #17) diff --git a/tests/unit/authz/route-guard-tunnel-processes-local-only.test.ts b/tests/unit/authz/route-guard-tunnel-processes-local-only.test.ts new file mode 100644 index 0000000000..cade32bb56 --- /dev/null +++ b/tests/unit/authz/route-guard-tunnel-processes-local-only.test.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + LOCAL_ONLY_API_GET_EXEMPTIONS, + LOCAL_ONLY_API_PREFIXES, + isLocalOnlyPath, +} from "../../../src/server/authz/routeGuard.ts"; +import { SPAWN_CAPABLE_PREFIXES } from "../../../src/shared/constants/spawnCapablePrefixes.ts"; + +const PROCESS_ROUTES = [ + "/api/tunnels/cloudflared", + "/api/tunnels/tailscale/disable", + "/api/tunnels/tailscale/enable", + "/api/tunnels/tailscale/install", + "/api/tunnels/tailscale/login", + "/api/tunnels/tailscale/start-daemon", +] as const; + +test("tunnel process routes are local-only and non-bypassable", () => { + for (const path of PROCESS_ROUTES) { + assert.ok(LOCAL_ONLY_API_PREFIXES.includes(path), `${path} must be explicitly local-only`); + assert.ok( + SPAWN_CAPABLE_PREFIXES.includes(path), + `${path} must be denied from the manage-scope bypass` + ); + assert.equal(isLocalOnlyPath(path, "POST"), true, `${path} POST must be local-only`); + } +}); + +test("cloudflared status remains remotely available for authenticated callers", () => { + assert.ok(LOCAL_ONLY_API_GET_EXEMPTIONS.has("/api/tunnels/cloudflared")); + assert.equal(isLocalOnlyPath("/api/tunnels/cloudflared", "GET"), false); + assert.equal(isLocalOnlyPath("/api/tunnels/cloudflared", "HEAD"), false); + assert.equal(isLocalOnlyPath("/api/tunnels/cloudflared", "OPTIONS"), false); + assert.equal(isLocalOnlyPath("/api/tunnels/cloudflared", "POST"), true); +}); + +test("Tailscale read-only status routes are not broadened into local-only", () => { + for (const path of ["/api/tunnels/tailscale", "/api/tunnels/tailscale/check"]) { + assert.equal(LOCAL_ONLY_API_PREFIXES.includes(path), false); + assert.equal(SPAWN_CAPABLE_PREFIXES.includes(path), false); + assert.equal(isLocalOnlyPath(path, "GET"), false); + } +}); + +test("unrelated tunnel paths remain outside the spawn-capable classification", () => { + for (const path of ["/api/tunnels/ngrok", "/api/tunnels/tailscale/status"]) { + assert.equal(LOCAL_ONLY_API_PREFIXES.includes(path), false); + assert.equal(SPAWN_CAPABLE_PREFIXES.includes(path), false); + assert.equal(isLocalOnlyPath(path, "GET"), false); + } +}); diff --git a/tests/unit/authz/spawn-capable-prefixes-client-safe.test.ts b/tests/unit/authz/spawn-capable-prefixes-client-safe.test.ts index 8a2c6b9e17..f28e4e8c80 100644 --- a/tests/unit/authz/spawn-capable-prefixes-client-safe.test.ts +++ b/tests/unit/authz/spawn-capable-prefixes-client-safe.test.ts @@ -84,11 +84,17 @@ test("SPAWN_CAPABLE_PREFIXES is defined in the server-free constants leaf with t "/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", ]) { assert.ok( SPAWN_CAPABLE_PREFIXES.includes(prefix), `SPAWN_CAPABLE_PREFIXES lost the spawn-capable prefix "${prefix}" during extraction` ); } - assert.equal(SPAWN_CAPABLE_PREFIXES.length, 14); + assert.equal(SPAWN_CAPABLE_PREFIXES.length, 20); });