fix(oauth): classify /api/oauth/cursor/auto-import as local-only (route-guard) (#5070)

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.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-26 01:33:42 -03:00
committed by GitHub
parent 2e5a0e03a5
commit 749009736e
2 changed files with 20 additions and 0 deletions

View File

@@ -40,6 +40,7 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray<string> = [
"/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.
];
/**

View File

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