fix(authz): restrict tunnel process routes locally (#11531)

Validated in a combined 10-PR batch worktree off release/v3.8.51 tip.
- Focused tests: tests/unit/authz/route-guard-tunnel-processes-local-only.test.ts + tests/unit/authz/spawn-capable-prefixes-client-safe.test.ts — 6+ pass
- typecheck:core, file-size, changelog-integrity, complexity, cognitive-complexity gates — all OK
- Full-repo lint: 503 pre-existing problems confirmed identical on the pure release/v3.8.51 tip — unrelated to this diff

⚠️ base-red inherited: #11449

Thanks for closing the process-spawning tunnel routes to local-only (Hard Rule #15/#17 territory) while preserving authenticated remote read access to status endpoints.
This commit is contained in:
Paco Cartones
2026-08-25 18:12:34 +02:00
committed by GitHub
parent 30026b2e96
commit e48ffd38d3
5 changed files with 77 additions and 2 deletions

View File

@@ -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.

View File

@@ -40,6 +40,12 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray<string> = [
"/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<string> = new Set(["/api/system/version"]);
export const LOCAL_ONLY_API_GET_EXEMPTIONS: ReadonlySet<string> = 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"]);

View File

@@ -27,6 +27,12 @@ export const SPAWN_CAPABLE_PREFIXES: ReadonlyArray<string> = [
"/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)

View File

@@ -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);
}
});

View File

@@ -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);
});