From 749009736e4cee77686b88a42e88a643f22517ae Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 26 Jun 2026 01:33:42 -0300 Subject: [PATCH] fix(oauth): classify /api/oauth/cursor/auto-import as local-only (route-guard) (#5070) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Cursor auto-import route runs execFile("which", ["cursor"]) to verify a local Cursor install before importing credentials — a child-process spawn. The check:route-guard-membership gate (Hard Rules #15/#17) flagged it as an unclassified spawn-capable route: reachable past the loopback gate, an RCE-via-tunnel surface (a leaked JWT over a tunnel could trigger the spawn). Classify the specific path in LOCAL_ONLY_API_PREFIXES so loopback enforcement runs unconditionally before any auth check. Scoped to the exact path — the rest of /api/oauth/ (browser redirect/callback flows) stays remote-reachable. TDD: added a failing-then-passing assertion in route-guard-local-prefix.test.ts (classification + an over-broadening guard proving sibling OAuth paths stay remote). check:route-guard-membership now reports 0 new gaps. --- src/server/authz/routeGuard.ts | 1 + .../authz/route-guard-local-prefix.test.ts | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/server/authz/routeGuard.ts b/src/server/authz/routeGuard.ts index 0f7d419cf5..a5fda0bc5d 100644 --- a/src/server/authz/routeGuard.ts +++ b/src/server/authz/routeGuard.ts @@ -40,6 +40,7 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray = [ "/api/local/", // T-12: 1-click local service launchers (Redis today; spawns podman/docker) — loopback-enforced by isLocalRequestAllowed() in src/lib/security/localEndpoints.ts (Hard Rules #15 + #17) "/api/headroom/start", // Headroom token-saver proxy lifecycle: spawns headroom-ai python CLI (Hard Rules #15 + #17) "/api/headroom/stop", // Headroom token-saver proxy lifecycle: sends SIGTERM/SIGKILL to managed PID (Hard Rules #15 + #17) + "/api/oauth/cursor/auto-import", // spawns `execFile("which", ["cursor"])` to verify a local Cursor install before importing creds — RCE-via-tunnel surface (Hard Rules #15 + #17, found by 6A.8 route-guard gate). Specific path only: the rest of /api/oauth/ (browser redirect/callback flows) must stay remote-reachable. ]; /** diff --git a/tests/unit/authz/route-guard-local-prefix.test.ts b/tests/unit/authz/route-guard-local-prefix.test.ts index 54cb8538fc..cce40edc55 100644 --- a/tests/unit/authz/route-guard-local-prefix.test.ts +++ b/tests/unit/authz/route-guard-local-prefix.test.ts @@ -27,6 +27,25 @@ test("isLocalOnlyPath: /api/local* does NOT match the bare /api/localifications assert.equal(isLocalOnlyPath("/api/localhost-check"), false); }); +// ─── /api/oauth/cursor/auto-import is local-only (spawns `which cursor`) ── + +test("isLocalOnlyPath: /api/oauth/cursor/auto-import is local-only (spawns child process)", () => { + // The Cursor auto-import route runs `execFile("which", ["cursor"])` to verify a + // local Cursor install before importing its credentials — a child-process spawn + // that must be loopback-enforced BEFORE any auth check (Hard Rules #15/#17), so a + // leaked JWT via tunnel cannot reach the spawn. Found by check:route-guard-membership. + assert.equal(isLocalOnlyPath("/api/oauth/cursor/auto-import"), true); +}); + +test("isLocalOnlyPath: the rest of /api/oauth/ stays remote-reachable (no over-broadening)", () => { + // Only the spawn-capable auto-import path is loopback-locked. The rest of the OAuth + // surface (browser redirect / callback flows) MUST remain reachable remotely — a + // flat /api/oauth/ prefix would wrongly lock the whole OAuth subtree. + assert.equal(isLocalOnlyPath("/api/oauth/cursor"), false); + assert.equal(isLocalOnlyPath("/api/oauth/cursor/callback"), false); + assert.equal(isLocalOnlyPath("/api/oauth/anthropic/callback"), false); +}); + test("isLocalOnlyBypassableByManageScope: /api/local/ is NOT bypassable (defence in depth)", () => { // The kill-switch path. Even if a DB row tries to whitelist /api/local/ via // the manage-scope bypass list, the runtime predicate must reject it because