From df3b691809324531eef63d7ea49fd96563ecede8 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Fri, 31 Jul 2026 10:42:01 -0400 Subject: [PATCH] feat(cursor): adds local-only manual refresh route Adds POST /api/providers/[id]/refresh-cursor, a dedicated loopback-only route that calls the renewal orchestrator on demand for a single Cursor connection, bounded by a 30s per-connection cooldown. Classifies the new route in LOCAL_ONLY_API_PATTERNS and closes the manage-scope-bypass gap for dynamic-segment spawn-capable routes under /api/providers/ via a new SPAWN_CAPABLE_PATTERNS / SPAWN_CAPABLE_PATTERN_ANCESTORS mechanism, which also retroactively covers the pre-existing /login route. The existing shared /api/providers/[id]/refresh route is untouched and stays remote-reachable for every other provider. --- docs/security/ROUTE_GUARD_TIERS.md | 60 ++-- .../providers/[id]/refresh-cursor/route.ts | 124 +++++++ src/server/authz/routeGuard.ts | 41 ++- src/shared/constants/spawnCapablePrefixes.ts | 28 ++ src/shared/validation/settingsSchemas.ts | 15 +- tests/unit/refresh-cursor-route.test.ts | 310 ++++++++++++++++++ tests/unit/route-guard-cursor-refresh.test.ts | 69 ++++ tests/unit/settings/authz-bypass.test.ts | 69 ++++ 8 files changed, 677 insertions(+), 39 deletions(-) create mode 100644 src/app/api/providers/[id]/refresh-cursor/route.ts create mode 100644 tests/unit/refresh-cursor-route.test.ts create mode 100644 tests/unit/route-guard-cursor-refresh.test.ts diff --git a/docs/security/ROUTE_GUARD_TIERS.md b/docs/security/ROUTE_GUARD_TIERS.md index 8933e3b419..3fd1f7e16a 100644 --- a/docs/security/ROUTE_GUARD_TIERS.md +++ b/docs/security/ROUTE_GUARD_TIERS.md @@ -39,22 +39,23 @@ spawn-capable route: a leaked token over a tunnel still can't reach the spawn. `check-route-guard-membership` gate enumerates every `route.ts` under the spawn-capable prefixes and fails CI if any is not classified local-only. -| Prefix / pattern | Why it's local-only | Manage-scope bypassable? | -| ----------------------------------- | ---------------------------------------------------------------------------------------- | ----------------------------- | -| `/api/mcp/` | MCP server — spawns stdio bridges + SSE handlers | **Yes** (only one) | -| `/api/cli-tools/runtime/` | CLI tool runtime — executes arbitrary plugin code | No — spawn-capable | -| `/api/services/` | Embedded services (9router/CLIProxy) — `npm install` + spawn | No — spawn-capable | -| `/dashboard/providers/services/` | Reverse proxy to embedded-service UIs | No | -| `/api/copilot/` | Unauthenticated LLM driver — CLI-only by default | Operator opt-in: manage/admin | -| `/api/tools/agent-bridge/` | AgentBridge — spawns MITM server + DNS edits | No — spawn-capable | -| `/api/tools/traffic-inspector/` | Traffic Inspector — http-proxy listener + system proxy | No — spawn-capable | -| `/api/plugins/`, `/api/plugins` | Plugins — load/execute via `worker_threads` + `child_process` | No — spawn-capable | -| `/api/system/version` | Auto-update (POST only; GET/HEAD/OPTIONS exempt) — spawns `git checkout` + `npm install` | No | -| `/api/db-backups/exportAll` | Spawns `tar` for the export archive | No | -| `/api/local/` | 1-click local launchers (Redis today) — spawns podman/docker | No — spawn-capable | -| `/api/headroom/start`, `/stop` | Headroom proxy lifecycle — spawns python CLI / signals PID | No — spawn-capable | -| `/api/oauth/cursor/auto-import` | `execFile("which", ["cursor"])` before importing creds | No | -| `/api/providers/{id}/login` (regex) | Launches a headful Playwright Chromium for web-cookie login | No | +| Prefix / pattern | Why it's local-only | Manage-scope bypassable? | +| -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | +| `/api/mcp/` | MCP server — spawns stdio bridges + SSE handlers | **Yes** (only one) | +| `/api/cli-tools/runtime/` | CLI tool runtime — executes arbitrary plugin code | No — spawn-capable | +| `/api/services/` | Embedded services (9router/CLIProxy) — `npm install` + spawn | No — spawn-capable | +| `/dashboard/providers/services/` | Reverse proxy to embedded-service UIs | No | +| `/api/copilot/` | Unauthenticated LLM driver — CLI-only by default | Operator opt-in: manage/admin | +| `/api/tools/agent-bridge/` | AgentBridge — spawns MITM server + DNS edits | No — spawn-capable | +| `/api/tools/traffic-inspector/` | Traffic Inspector — http-proxy listener + system proxy | No — spawn-capable | +| `/api/plugins/`, `/api/plugins` | Plugins — load/execute via `worker_threads` + `child_process` | No — spawn-capable | +| `/api/system/version` | Auto-update (POST only; GET/HEAD/OPTIONS exempt) — spawns `git checkout` + `npm install` | No | +| `/api/db-backups/exportAll` | Spawns `tar` for the export archive | No | +| `/api/local/` | 1-click local launchers (Redis today) — spawns podman/docker | No — spawn-capable | +| `/api/headroom/start`, `/stop` | Headroom proxy lifecycle — spawns python CLI / signals PID | No — spawn-capable | +| `/api/oauth/cursor/auto-import` | `execFile("which", ["cursor"])` before importing creds | No | +| `/api/providers/{id}/login` (regex) | Launches a headful Playwright Chromium for web-cookie login | No | +| `/api/providers/{id}/refresh-cursor` (regex) | Manual Cursor session renewal — nudges `cursor-agent` (`--list-models`/`status` via `src/lib/cursor/renewal.ts`); the rest of `/api/providers/`, including the generic `/refresh`, intentionally stays remote-reachable | No — spawn-capable | **Response on violation:** `403 LOCAL_ONLY` @@ -84,15 +85,15 @@ ever be added), and it is deliberately excluded from carve-out exactly as before; `mcp:connect` is a lower-privilege alternative for remote MCP-only callers who should not need broad management access. -| Request | Path | Result | -| ------------------------------------------------- | -------------------------- | ------------------- | -| Non-loopback, no Bearer | `/api/mcp/*` | 403 LOCAL_ONLY | -| Non-loopback, Bearer with `manage` scope | `/api/mcp/*` | Allow | -| Non-loopback, Bearer with `mcp:connect` scope | `/api/mcp/*` | Allow | -| Non-loopback, Bearer without `manage`/`mcp:connect` | `/api/mcp/*` | 403 LOCAL_ONLY | -| Non-loopback, Bearer with `mcp:connect` scope | `/api/cli-tools/runtime/*` | 403 LOCAL_ONLY | -| Non-loopback, Bearer with `manage` scope | `/api/cli-tools/runtime/*` | 403 LOCAL_ONLY | -| Loopback, any/no Bearer | any LOCAL_ONLY | Allow (gate passes) | +| Request | Path | Result | +| --------------------------------------------------- | -------------------------- | ------------------- | +| Non-loopback, no Bearer | `/api/mcp/*` | 403 LOCAL_ONLY | +| Non-loopback, Bearer with `manage` scope | `/api/mcp/*` | Allow | +| Non-loopback, Bearer with `mcp:connect` scope | `/api/mcp/*` | Allow | +| Non-loopback, Bearer without `manage`/`mcp:connect` | `/api/mcp/*` | 403 LOCAL_ONLY | +| Non-loopback, Bearer with `mcp:connect` scope | `/api/cli-tools/runtime/*` | 403 LOCAL_ONLY | +| Non-loopback, Bearer with `manage` scope | `/api/cli-tools/runtime/*` | 403 LOCAL_ONLY | +| Loopback, any/no Bearer | any LOCAL_ONLY | Allow (gate passes) | #### Operator guidance & auditing @@ -110,7 +111,14 @@ operator responsibilities remain: only with a `manage`-scoped API key. The `SPAWN_CAPABLE_PREFIXES` can never be added to the bypass list — the zod schema rejects them and `isLocalOnlyBypassableByManageScope` denies them at runtime (defence-in-depth), - which is what the dashboard means by "cannot be made bypassable". + which is what the dashboard means by "cannot be made bypassable". Dynamic-segment + and static-path spawn-capable routes under `/api/providers/` (e.g. `/login`, + `/refresh-cursor`) are covered by the regex-based `SPAWN_CAPABLE_PATTERNS` / + `SPAWN_CAPABLE_PATTERN_ANCESTORS` companion in + `src/shared/constants/spawnCapablePrefixes.ts`, not by the flat + `SPAWN_CAPABLE_PREFIXES` array — the flat array would have to cover the + entire `/api/providers/` prefix to catch them, over-broadening a route tree + remote dashboards legitimately use for provider CRUD. **Auditing access** — to verify nothing off-host is reaching these routes: diff --git a/src/app/api/providers/[id]/refresh-cursor/route.ts b/src/app/api/providers/[id]/refresh-cursor/route.ts new file mode 100644 index 0000000000..9d47210f59 --- /dev/null +++ b/src/app/api/providers/[id]/refresh-cursor/route.ts @@ -0,0 +1,124 @@ +import { NextResponse } from "next/server"; +import { getCachedProviderConnectionById } from "@/lib/localDb"; +import { updateProviderConnection } from "@/lib/db/providers"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; +import { + renewCursorConnection, + buildCursorRenewedUpdate, + runCursorRenewalExclusive, +} from "@/lib/cursor/renewal"; + +interface CursorConnectionLike { + id: string; + provider?: string; + accessToken?: string; + expiresAt?: string | null; + providerSpecificData?: Record | null; +} + +const MANUAL_REFRESH_COOLDOWN_MS = 30_000; + +/** + * Per-connection cooldown for rapid sequential (non-concurrent) manual + * "Refresh" clicks. Bounds repeated real authenticated `--list-models` calls + * to Cursor's API from a user mashing the button — independent of, and much + * shorter than, the sweep's `isInRefreshBackoff()` circuit (left untouched). + * The in-flight-spawn lock (Task 2 Step 3) already caps CONCURRENT spawns; + * this caps repeated SEQUENTIAL ones, which that lock does not throttle. + */ +const lastManualRefreshAttemptAt = new Map(); + +/** + * POST /api/providers/[id]/refresh-cursor + * Manually trigger a Cursor session renewal attempt (nudge `cursor-agent`, + * re-scrape IDE/agent credential sources). Dedicated route because Cursor has + * no refresh_token by design — the generic `/api/providers/[id]/refresh` + * route always silently 502s for Cursor connections today. + * + * 🔒 LOCAL_ONLY — classified in `LOCAL_ONLY_API_PATTERNS` + * (`src/server/authz/routeGuard.ts`) because `renewCursorConnection()` spawns + * `cursor-agent` as a child process (Hard Rules #15 + #17). Unlike this + * route, the rest of `/api/providers/` (including the generic `/refresh`) + * intentionally remains remote-reachable. + */ +export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + + const connection = (await getCachedProviderConnectionById(id)) as CursorConnectionLike | null; + if (!connection) { + return NextResponse.json({ error: "Connection not found" }, { status: 404 }); + } + + if (connection.provider !== "cursor") { + return NextResponse.json( + { error: "This route only supports Cursor connections" }, + { status: 400 } + ); + } + + const lastAttempt = lastManualRefreshAttemptAt.get(connection.id) ?? 0; + const elapsedMs = Date.now() - lastAttempt; + if (elapsedMs < MANUAL_REFRESH_COOLDOWN_MS) { + const retryAfterMs = MANUAL_REFRESH_COOLDOWN_MS - elapsedMs; + return NextResponse.json( + { + error: "Refresh already attempted recently — please wait before retrying.", + retryAfterMs, + }, + { + status: 429, + headers: { "Retry-After": String(Math.ceil(retryAfterMs / 1000)) }, + } + ); + } + // Set immediately before invoking renewCursorConnection() — regardless of + // outcome — so rapid repeated clicks are throttled either way. + lastManualRefreshAttemptAt.set(connection.id, Date.now()); + + return await runCursorRenewalExclusive(connection.id, async () => { + const result = await renewCursorConnection({ + accessToken: connection.accessToken ?? "", + machineId: connection.providerSpecificData?.machineId as string | null | undefined, + }); + + if (result.status === "renewed") { + const now = new Date().toISOString(); + const update = buildCursorRenewedUpdate(connection, result, now); + await updateProviderConnection(connection.id, update); + return NextResponse.json({ + success: true, + connectionId: connection.id, + provider: "cursor", + expiresAt: update.expiresAt as string, + refreshedAt: now, + }); + } + + if (result.status === "unchanged") { + return NextResponse.json({ + success: true, + unchanged: true, + connectionId: connection.id, + provider: "cursor", + expiresAt: connection.expiresAt ?? null, + refreshedAt: new Date().toISOString(), + message: "Cursor session is already current — no newer token found on this host.", + }); + } + + return NextResponse.json( + { error: "Token refresh failed — provider returned no new token", details: result.error }, + { status: 502 } + ); + }); + } catch (error) { + return NextResponse.json( + { + error: "Token refresh failed", + details: sanitizeErrorMessage(error instanceof Error ? error.message : String(error)), + }, + { status: 500 } + ); + } +} diff --git a/src/server/authz/routeGuard.ts b/src/server/authz/routeGuard.ts index be7220b00a..f4381b24f7 100644 --- a/src/server/authz/routeGuard.ts +++ b/src/server/authz/routeGuard.ts @@ -22,7 +22,10 @@ */ import { getAuthzBypassSnapshot } from "@/lib/config/runtimeSettings"; -import { SPAWN_CAPABLE_PREFIXES } from "@/shared/constants/spawnCapablePrefixes"; +import { + SPAWN_CAPABLE_PREFIXES, + SPAWN_CAPABLE_PATTERNS, +} from "@/shared/constants/spawnCapablePrefixes"; import { VNC_ROUTE_PREFIX } from "@/lib/vncSession/manifest"; const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]); @@ -62,24 +65,37 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray = [ * parameter, so a flat prefix in `LOCAL_ONLY_API_PREFIXES` cannot target them * without over-broadening (e.g. locking the entire `/api/providers/` subtree, * which remote dashboards legitimately use for provider CRUD). These are matched - * by regex instead. + * by regex instead against the concrete resolved path — which is already + * `request.nextUrl.pathname` (see `runAuthzPipeline`/`classifyRoute`), the + * SAME string Next.js's own file-based router uses to resolve the `[id]` + * dynamic segment, so there is no decode/normalization mismatch between what + * this regex sees and what actually gets dispatched to the route handler. * * - `POST /api/providers/{id}/login` launches a headful Playwright Chromium * (a child process) to drive a web-cookie login. Loopback enforcement must * happen unconditionally before any auth check (Hard Rules #15 + #17), so a * leaked JWT via tunnel cannot trigger a browser spawn. + * - `POST /api/providers/{id}/refresh-cursor` nudges `cursor-agent` + * (`--list-models`/`status`, via `src/lib/cursor/renewal.ts`) as part of + * a manual Cursor session renewal attempt — the same RCE-via-tunnel + * surface (Hard Rules #15 + #17). The rest of `/api/providers/`, + * including the generic `/refresh` route, intentionally stays + * remote-reachable — only this Cursor-specific spawn-capable path is + * gated, matching the `/login` precedent's narrow-scoping rationale. */ export const LOCAL_ONLY_API_PATTERNS: ReadonlyArray = [ /^\/api\/providers\/[^/]+\/login\/?$/, + /^\/api\/providers\/[^/]+\/refresh-cursor\/?$/, ]; -// `SPAWN_CAPABLE_PREFIXES` (the spawn-capable deny-list) now lives in the -// server-free leaf module `@/shared/constants/spawnCapablePrefixes` so that -// client-reachable validation schemas can import it without pulling this module's -// server runtime (runtimeSettings → localDb → ioredis) into the browser bundle. +// `SPAWN_CAPABLE_PREFIXES` / `SPAWN_CAPABLE_PATTERNS` (the spawn-capable +// deny-lists) now live in the server-free leaf module +// `@/shared/constants/spawnCapablePrefixes` so that client-reachable +// validation schemas can import them without pulling this module's server +// runtime (runtimeSettings → localDb → ioredis) into the browser bundle. // Imported above for the runtime check in `isLocalOnlyBypassableByManageScope`; // re-exported here so existing `@/server/authz/routeGuard` importers keep working. -export { SPAWN_CAPABLE_PREFIXES }; +export { SPAWN_CAPABLE_PREFIXES, SPAWN_CAPABLE_PATTERNS }; /** * Compile-time default of the manage-scope bypass list. Kept as an exported @@ -174,9 +190,7 @@ 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"]); /** Safe HTTP methods that can be exempted for read-only paths. */ const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); @@ -221,6 +235,13 @@ export function isLocalOnlyPath(path: string, method?: string): boolean { * O(1) (no I/O, no async). Hot-reload SLA: <50 ms — satisfied structurally. */ export function isLocalOnlyBypassableByManageScope(path: string): boolean { + // Precise, unconditional early-deny for regex-matched spawn-capable routes + // (e.g. /api/providers/{id}/login, /api/providers/{id}/refresh-cursor). + // Unlike the flat-prefix defence-in-depth check below, this has the + // concrete resolved `path` already, so it's an exact match — no + // reachability heuristics needed. + if (SPAWN_CAPABLE_PATTERNS.some((re) => re.test(path))) return false; + const snapshot = getAuthzBypassSnapshot(); if (!snapshot.enabled) return false; return snapshot.prefixes.some((p) => { diff --git a/src/shared/constants/spawnCapablePrefixes.ts b/src/shared/constants/spawnCapablePrefixes.ts index b5bdfd4dc5..5357cef34c 100644 --- a/src/shared/constants/spawnCapablePrefixes.ts +++ b/src/shared/constants/spawnCapablePrefixes.ts @@ -36,3 +36,31 @@ export const SPAWN_CAPABLE_PREFIXES: ReadonlyArray = [ "/api/headroom/stop", // kills tracked PID — must never be bypassable (Hard Rules #15 + #17) "/api/vnc-session", // #7892: spawns Docker containers via child_process.spawn (src/lib/vncSession/service.ts) — must never be whitelistable via manage-scope bypass (Hard Rules #15 + #17) ]; + +/** + * Regex-matched companion to `SPAWN_CAPABLE_PREFIXES`, for spawn-capable + * routes whose spawn-capable segment sits AFTER a dynamic path parameter + * (e.g. `/api/providers/{id}/refresh-cursor`) — a flat prefix would either + * miss them entirely or require over-broadening the shared `/api/providers/` + * prefix (used for legitimate remote provider CRUD). Mirrors the + * `LOCAL_ONLY_API_PREFIXES`/`LOCAL_ONLY_API_PATTERNS` split already + * established in `routeGuard.ts` for this exact shape. Checked against a + * CONCRETE resolved request path — an exact regex match, no approximation. + */ +export const SPAWN_CAPABLE_PATTERNS: ReadonlyArray = [ + /^\/api\/providers\/[^/]+\/login\/?$/, // pre-existing gap: in LOCAL_ONLY_API_PATTERNS today but never in a spawn-capable deny-list + /^\/api\/providers\/[^/]+\/refresh-cursor\/?$/, // spawns cursor-agent via renewal.ts (Hard Rules #15 + #17) +]; + +/** + * Companion to `SPAWN_CAPABLE_PATTERNS`, used ONLY by the zod-level candidate + * bypass-prefix check (`settingsSchemas.ts`), which validates a candidate + * BYPASS PREFIX STRING (not a concrete path) at `PATCH /api/settings` time — + * general prefix-vs-regex reachability is undecidable, so this conservatively + * treats the shared literal ancestor of the dynamic/static-segment patterns + * as off-limits. Intentionally coarser than `SPAWN_CAPABLE_PATTERNS`'s exact + * per-route match, but costs nothing security-wise: the runtime check in + * `isLocalOnlyBypassableByManageScope` (Layer 2) is the actual enforcement + * boundary and stays exact. `SPAWN_CAPABLE_PREFIXES` itself is untouched. + */ +export const SPAWN_CAPABLE_PATTERN_ANCESTORS: ReadonlyArray = ["/api/providers/"]; diff --git a/src/shared/validation/settingsSchemas.ts b/src/shared/validation/settingsSchemas.ts index 29b3d0756c..9fab457ba5 100644 --- a/src/shared/validation/settingsSchemas.ts +++ b/src/shared/validation/settingsSchemas.ts @@ -15,7 +15,10 @@ import { RESPONSES_PREVIOUS_RESPONSE_ID_MODES } from "@/shared/constants/respons // Import from the server-free constants leaf, NOT from `@/server/authz/routeGuard`: // this schema is reachable from client components (dashboard onboarding wizard), and // routeGuard drags in server runtime (→ ioredis) that breaks the client/CLI build. -import { SPAWN_CAPABLE_PREFIXES } from "@/shared/constants/spawnCapablePrefixes"; +import { + SPAWN_CAPABLE_PREFIXES, + SPAWN_CAPABLE_PATTERN_ANCESTORS, +} from "@/shared/constants/spawnCapablePrefixes"; const signatureCacheModeValues = ["enabled", "bypass", "bypass-strict"] as const; @@ -135,7 +138,10 @@ export const updateSettingsSchema = z.object({ showProviderTopologyOnHome: z.boolean().optional(), localOnlyManageScopeBypassEnabled: z.boolean().optional(), // Layer 1 of the spawn-capable guard (Hard Rules #15/#17): reject any bypass - // prefix that reaches a SPAWN_CAPABLE_PREFIXES path at PATCH time, with the + // prefix that reaches a SPAWN_CAPABLE_PREFIXES path, or a + // SPAWN_CAPABLE_PATTERN_ANCESTORS ancestor (e.g. /api/providers/, the + // shared ancestor of the dynamic-segment routes in SPAWN_CAPABLE_PATTERNS + // such as /login and /refresh-cursor), at PATCH time, with the // BYPASS_PREFIX_NOT_ALLOWED code the settings route handler translates. // Layer 2 (isLocalOnlyBypassableByManageScope) still refuses spawn paths at // runtime even if a malformed DB row claims otherwise. This refine was in the @@ -149,7 +155,10 @@ export const updateSettingsSchema = z.object({ .refine( (prefix) => { const normalized = prefix.endsWith("/") ? prefix : `${prefix}/`; - return !SPAWN_CAPABLE_PREFIXES.some((sp) => normalized.startsWith(sp)); + return ( + !SPAWN_CAPABLE_PREFIXES.some((sp) => normalized.startsWith(sp)) && + !SPAWN_CAPABLE_PATTERN_ANCESTORS.some((sp) => normalized.startsWith(sp)) + ); }, { message: diff --git a/tests/unit/refresh-cursor-route.test.ts b/tests/unit/refresh-cursor-route.test.ts new file mode 100644 index 0000000000..983de76e4a --- /dev/null +++ b/tests/unit/refresh-cursor-route.test.ts @@ -0,0 +1,310 @@ +/** + * POST /api/providers/[id]/refresh-cursor (Cursor renewal plan, Task 4). + * + * Direct route.ts invocation, matching this codebase's existing precedent + * for testing App Router handlers without a running server (e.g. + * tests/unit/dahl-tokens-route.test.ts, tests/unit/agent-bridge-dns-params-7271.test.ts): + * import the exported POST function and call it with a real Request and + * `{ params: Promise.resolve({ id }) }`. + * + * Real DB (temp DATA_DIR, same convention as tests/unit/token-health-check-cursor.test.ts) + * and the same real fake-cursor-agent-binary + HOME-override technique from + * tests/unit/cursor-renewal.test.ts — renewCursorConnection() has no deps + * override at this call site either, so its dependencies are driven for + * real, never mocked. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +process.env.NODE_ENV = "test"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-refresh-cursor-route-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const { POST } = await import("../../src/app/api/providers/[id]/refresh-cursor/route.ts"); + +test.after(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +function getId(connection: { id?: unknown }): string { + assert.equal(typeof connection.id, "string"); + return connection.id as string; +} + +function makeRequest(): Request { + return new Request("http://localhost/api/providers/x/refresh-cursor", { method: "POST" }); +} + +function callRoute(id: string) { + return POST(makeRequest(), { params: Promise.resolve({ id }) }); +} + +// ---- Real fake cursor-agent binary + IDE/agent fixtures (mirrors +// tests/unit/cursor-renewal.test.ts / tests/unit/token-health-check-cursor.test.ts) ---- + +const FAKE_CURSOR_AGENT_SCRIPT = `#!/usr/bin/env node +const fs = require("fs"); +const args = process.argv.slice(2); +if (process.env.FAKE_CURSOR_AGENT_LOG) { + fs.appendFileSync(process.env.FAKE_CURSOR_AGENT_LOG, JSON.stringify(args) + "\\n"); +} +if (args[0] === "status") { + const mode = process.env.FAKE_CURSOR_AGENT_STATUS_MODE || "unauthenticated"; + if (mode === "authenticated") { + process.stdout.write(JSON.stringify({ status: "authenticated", isAuthenticated: true })); + } else { + process.stdout.write(JSON.stringify({ status: "unauthenticated", isAuthenticated: false })); + } +} +`; + +function writeFakeCursorAgentBinary(destPath: string): void { + fs.mkdirSync(path.dirname(destPath), { recursive: true }); + fs.writeFileSync(destPath, FAKE_CURSOR_AGENT_SCRIPT, { mode: 0o755 }); + fs.chmodSync(destPath, 0o755); +} + +function readLoggedInvocations(logPath: string): string[][] { + if (!fs.existsSync(logPath)) return []; + return fs + .readFileSync(logPath, "utf-8") + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)); +} + +interface CursorEnv { + tmpHome: string; + logPath: string; + writeIdeToken(accessToken: string, machineId?: string): Promise; + cleanup(): void; +} + +async function withCursorEnv(fn: (env: CursorEnv) => Promise): Promise { + const originalHome = process.env.HOME; + const originalUserProfile = process.env.USERPROFILE; + const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + + Object.defineProperty(process, "platform", { value: "darwin", configurable: true }); + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-refresh-cursor-route-env-")); + process.env.HOME = tmpHome; + process.env.USERPROFILE = tmpHome; + + const logPath = path.join(tmpHome, "log.jsonl"); + process.env.FAKE_CURSOR_AGENT_LOG = logPath; + process.env.FAKE_CURSOR_AGENT_STATUS_MODE = "unauthenticated"; + writeFakeCursorAgentBinary(path.join(tmpHome, ".local", "bin", "cursor-agent")); + + const env: CursorEnv = { + tmpHome, + logPath, + async writeIdeToken(accessToken, machineId) { + const { openDatabaseAsync } = await import("../../src/lib/db/adapters/driverFactory.ts"); + const dbPath = path.join( + tmpHome, + "Library/Application Support/Cursor/User/globalStorage/state.vscdb" + ); + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + const seed = await openDatabaseAsync(dbPath); + seed.exec("CREATE TABLE itemTable (key TEXT PRIMARY KEY, value TEXT)"); + seed + .prepare("INSERT INTO itemTable (key, value) VALUES (?, ?)") + .run("cursorAuth/accessToken", accessToken); + if (machineId) { + seed + .prepare("INSERT INTO itemTable (key, value) VALUES (?, ?)") + .run("storage.serviceMachineId", machineId); + } + seed.close(); + }, + cleanup() { + if (originalPlatformDescriptor) { + Object.defineProperty(process, "platform", originalPlatformDescriptor); + } + process.env.HOME = originalHome; + if (originalUserProfile !== undefined) process.env.USERPROFILE = originalUserProfile; + else delete process.env.USERPROFILE; + delete process.env.FAKE_CURSOR_AGENT_LOG; + delete process.env.FAKE_CURSOR_AGENT_STATUS_MODE; + fs.rmSync(tmpHome, { recursive: true, force: true }); + }, + }; + + try { + return await fn(env); + } finally { + env.cleanup(); + } +} + +async function createCursorConnection(overrides: Record = {}) { + const connection = await providersDb.createProviderConnection({ + provider: "cursor", + authType: "oauth", + email: `cursor-route-${Math.random()}@example.com`, + accessToken: "old-token", + refreshToken: null, + isActive: true, + testStatus: "active", + ...overrides, + }); + return getId(connection); +} + +test("renewed: returns 200 with the documented shape and persists the new token", async () => { + await withCursorEnv(async (env) => { + const id = await createCursorConnection(); + await env.writeIdeToken("new-ide-token", "new-machine"); + + const res = await callRoute(id); + const body = (await res.json()) as Record; + + assert.equal(res.status, 200); + assert.equal(body.success, true); + assert.equal(body.connectionId, id); + assert.equal(body.provider, "cursor"); + assert.ok(body.expiresAt); + assert.ok(body.refreshedAt); + assert.equal(body.unchanged, undefined); + + const updated = await providersDb.getProviderConnectionById(id); + assert.equal(updated?.accessToken, "new-ide-token"); + assert.equal(updated?.testStatus, "active"); + }); +}); + +test("unchanged: returns 200 {success:true, unchanged:true, ...} without a new token", async () => { + await withCursorEnv(async () => { + const id = await createCursorConnection({ + expiresAt: "2026-01-01T00:00:00.000Z", + }); + // No IDE/agent fixtures written -> renewCursorConnection() reports "unchanged". + + const res = await callRoute(id); + const body = (await res.json()) as Record; + + assert.equal(res.status, 200); + assert.equal(body.success, true); + assert.equal(body.unchanged, true); + assert.equal(body.connectionId, id); + assert.equal(body.provider, "cursor"); + assert.equal( + body.expiresAt, + "2026-01-01T00:00:00.000Z", + "echoes the connection's CURRENT (unchanged) expiresAt" + ); + assert.ok(body.refreshedAt); + assert.match(body.message as string, /already current/); + }); +}); + +test( + 'error: renewCursorConnection returning {status:"error"} -> 502', + { + skip: + "Same testability gap already flagged for C2/C3: renewCursorConnection() (called with " + + 'no deps override here, same as the sweep) never returns {status:"error"} from a ' + + "black-box test because tryIdeAuth()/tryAgentAuth() catch every internal failure and " + + "resolve to {found:false,...} rather than throwing. This route's 502 mapping " + + '(`{error: "Token refresh failed — provider returned no new token", details: result.error}`) ' + + "is a straight passthrough of result.error, already proven correctly sanitized in " + + "tests/unit/cursor-renewal.test.ts's case (d).", + }, + async () => {} +); + +test("non-Cursor connection -> 400", async () => { + const connection = await providersDb.createProviderConnection({ + provider: "openai", + authType: "oauth", + email: "not-cursor@example.com", + accessToken: "token", + refreshToken: "refresh", + isActive: true, + }); + const id = getId(connection); + + const res = await callRoute(id); + const body = (await res.json()) as Record; + + assert.equal(res.status, 400); + assert.match(body.error as string, /only supports Cursor connections/); +}); + +test("nonexistent connection -> 404", async () => { + const res = await callRoute("does-not-exist-" + Math.random()); + const body = (await res.json()) as Record; + + assert.equal(res.status, 404); + assert.match(body.error as string, /not found/i); +}); + +test("a second call within the 30s cooldown returns 429 with Retry-After, without invoking renewCursorConnection again", async () => { + await withCursorEnv(async (env) => { + const id = await createCursorConnection(); + // No fixtures -> first call resolves "unchanged", also sets the cooldown timestamp. + + const first = await callRoute(id); + assert.equal(first.status, 200); + const invocationsAfterFirst = readLoggedInvocations(env.logPath).length; + assert.ok( + invocationsAfterFirst >= 1, + "expected the first call to actually check cursor-agent availability" + ); + + const second = await callRoute(id); + assert.equal(second.status, 429); + assert.ok(second.headers.get("Retry-After"), "expected a Retry-After header"); + const secondBody = (await second.json()) as Record; + assert.ok(typeof secondBody.retryAfterMs === "number"); + assert.ok( + (secondBody.retryAfterMs as number) > 0 && (secondBody.retryAfterMs as number) <= 30_000 + ); + + assert.equal( + readLoggedInvocations(env.logPath).length, + invocationsAfterFirst, + "renewCursorConnection() must NOT be invoked a second time while in cooldown" + ); + }); +}); + +test("a different connection's request is unaffected by another connection's cooldown", async () => { + await withCursorEnv(async () => { + const idA = await createCursorConnection(); + const idB = await createCursorConnection(); + + const first = await callRoute(idA); + assert.equal(first.status, 200); + + const second = await callRoute(idB); + assert.equal( + second.status, + 200, + "a different connectionId must not be throttled by connection A's cooldown" + ); + }); +}); + +test("an unexpected thrown error is caught by the outer handler and returns 500 with sanitized details (no raw stack/path)", async () => { + const rawMessage = + "Simulated failure at /Users/secret-user/project/src/app/api/providers/[id]/refresh-cursor/route.ts:44:5"; + const res = await POST(makeRequest(), { + params: Promise.reject(new Error(rawMessage)) as unknown as Promise<{ id: string }>, + }); + const body = (await res.json()) as Record; + + assert.equal(res.status, 500); + assert.equal(body.error, "Token refresh failed"); + const details = body.details as string; + assert.ok(!details.includes("/Users/secret-user"), `raw path leaked: ${details}`); + assert.ok(!details.includes("route.ts:44:5"), `raw source location leaked: ${details}`); + assert.ok(!details.includes("at /"), `stack-trace-style substring leaked: ${details}`); +}); diff --git a/tests/unit/route-guard-cursor-refresh.test.ts b/tests/unit/route-guard-cursor-refresh.test.ts new file mode 100644 index 0000000000..a06044d67c --- /dev/null +++ b/tests/unit/route-guard-cursor-refresh.test.ts @@ -0,0 +1,69 @@ +/** + * Security regression (Cursor renewal plan, Task 4): POST + * /api/providers/[id]/refresh-cursor manually nudges `cursor-agent` (a child + * process, via src/lib/cursor/renewal.ts) to attempt a Cursor session + * renewal. It MUST be classified LOCAL_ONLY so loopback enforcement runs + * unconditionally before any auth check — a leaked JWT via a Cloudflared/ + * Ngrok tunnel cannot trigger a process spawn. Hard Rules #15 + #17. See + * docs/security/ROUTE_GUARD_TIERS.md. + * + * The refresh-cursor segment sits AFTER the dynamic `[id]` param, so it is + * matched by a regex in LOCAL_ONLY_API_PATTERNS rather than a flat prefix — + * classifying the whole `/api/providers/` subtree as LOCAL_ONLY would wrongly + * lock the remote dashboard out of ordinary provider CRUD (including the + * generic, remote-reachable `/refresh` route every OTHER provider uses). + * These tests pin BOTH the gate AND the narrowness (no over-match), mirroring + * tests/unit/route-guard-provider-login-local-only.test.ts's exact structure + * for the sibling `/login` regex entry. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + isLocalOnlyPath, + isLocalOnlyBypassableByManageScope, +} from "../../src/server/authz/routeGuard.ts"; + +test("/api/providers/[id]/refresh-cursor is LOCAL_ONLY (spawns cursor-agent)", () => { + assert.equal(isLocalOnlyPath("/api/providers/abc123/refresh-cursor"), true); + assert.equal(isLocalOnlyPath("/api/providers/conn-uuid-456/refresh-cursor"), true); +}); + +test("/api/providers/[id]/refresh-cursor with a trailing slash is LOCAL_ONLY", () => { + assert.equal(isLocalOnlyPath("/api/providers/abc123/refresh-cursor/"), true); +}); + +test("the dedicated-route decision worked: the generic /refresh route stays remote-reachable", () => { + // THE regression this whole route-split decision exists to prove: Cursor's + // manual-refresh gets its OWN dedicated LOCAL_ONLY route specifically so the + // pre-existing, shared /refresh route (used by every non-Cursor provider) + // is NOT reclassified and stays reachable from a remote dashboard. + assert.equal(isLocalOnlyPath("/api/providers/abc123/refresh"), false); + assert.equal(isLocalOnlyPath("/api/providers/conn-uuid-456/refresh"), false); +}); + +test("the refresh-cursor gate does NOT over-match the rest of /api/providers", () => { + assert.equal(isLocalOnlyPath("/api/providers"), false); + assert.equal(isLocalOnlyPath("/api/providers/"), false); + assert.equal(isLocalOnlyPath("/api/providers/abc123"), false); + assert.equal(isLocalOnlyPath("/api/providers/abc123/test"), false); + assert.equal(isLocalOnlyPath("/api/providers/abc123/models"), false); + // Anchored: extra segments after /refresh-cursor are not the spawn route. + assert.equal(isLocalOnlyPath("/api/providers/abc123/refresh-cursor/extra"), false); + // "refresh-cursor" must be its own segment, not a substring of the id. + assert.equal(isLocalOnlyPath("/api/providers/refresh-cursor-helper/status"), false); +}); + +test("isLocalOnlyBypassableByManageScope rejects refresh-cursor (spawn-capable, never bypassable via manage scope)", () => { + assert.equal(isLocalOnlyBypassableByManageScope("/api/providers/abc123/refresh-cursor"), false); + assert.equal(isLocalOnlyBypassableByManageScope("/api/providers/abc123/refresh-cursor/"), false); +}); + +test("isLocalOnlyBypassableByManageScope rejects login too — the retroactive gap-closure, not an incidental side effect", () => { + // Pins the plan's explicitly-noted side effect: the same SPAWN_CAPABLE_PATTERNS + // fix that protects refresh-cursor also retroactively closes a PRE-EXISTING + // gap for /login (which was in LOCAL_ONLY_API_PATTERNS but never in any + // spawn-capable deny-list before this plan). A regression here would mean + // a malformed DB bypass-prefix row could grant remote access to a route + // that spawns a headful Playwright Chromium. + assert.equal(isLocalOnlyBypassableByManageScope("/api/providers/abc123/login"), false); +}); diff --git a/tests/unit/settings/authz-bypass.test.ts b/tests/unit/settings/authz-bypass.test.ts index 1ba6302764..5088c19523 100644 --- a/tests/unit/settings/authz-bypass.test.ts +++ b/tests/unit/settings/authz-bypass.test.ts @@ -271,6 +271,75 @@ test("AC-8: PATCH with /api/cli-tools/runtime/ in bypass list → 400 BYPASS_PRE assert.equal(snapshotAfter.enabled, snapshotBefore.enabled); }); +// ─── Cursor renewal plan, Task 4 Step 3: the new SPAWN_CAPABLE_PATTERNS / +// SPAWN_CAPABLE_PATTERN_ANCESTORS mechanism must reject a candidate bypass +// prefix of "/api/providers/" (which would otherwise cover both the new +// refresh-cursor route AND the pre-existing /login route) while leaving an +// unrelated, already-passing prefix untouched ──────────────────────────── + +test("PATCH with /api/providers/ in bypass list → 400 BYPASS_PREFIX_NOT_ALLOWED + snapshot unchanged (SPAWN_CAPABLE_PATTERN_ANCESTORS)", async () => { + process.env.JWT_SECRET = "test-jwt-secret-authz-bypass"; + process.env.INITIAL_PASSWORD = "initial-pass-cursor-t4"; + await settingsDb.updateSettings({ requireLogin: true }); + const { ensurePersistentManagementPasswordHash } = + await import("../../../src/lib/auth/managementPassword.ts"); + await ensurePersistentManagementPasswordHash({ source: "test.bootstrap" }); + const seeded = await settingsDb.getSettings(); + await runtime.applyRuntimeSettings(seeded); + const snapshotBefore = runtime.getAuthzBypassSnapshot(); + + const response = await settingsRoute.PATCH( + await makeManagementSessionRequest("http://localhost/api/settings", { + method: "PATCH", + body: { + localOnlyManageScopeBypassPrefixes: ["/api/mcp/", "/api/providers/"], + currentPassword: "initial-pass-cursor-t4", + }, + }) + ); + + assert.equal(response.status, 400); + const body = (await response.json()) as { + error: { details?: Array<{ field: string; message: string }> }; + }; + const offending = body.error.details?.find((d) => + d.message.includes("BYPASS_PREFIX_NOT_ALLOWED") + ); + assert.ok(offending, `expected BYPASS_PREFIX_NOT_ALLOWED in details: ${JSON.stringify(body)}`); + + // The unrelated, already-passing "/api/mcp/" prefix is untouched by this + // rejection — no regression to the existing Layer-1 check: persisted state + // stays at its prior valid value, not silently split-accepted. + const settings = await settingsDb.getSettings(); + assert.deepEqual(settings.localOnlyManageScopeBypassPrefixes, ["/api/mcp/"]); + const snapshotAfter = runtime.getAuthzBypassSnapshot(); + assert.deepEqual(snapshotAfter.prefixes, snapshotBefore.prefixes); + assert.equal(snapshotAfter.enabled, snapshotBefore.enabled); +}); + +test("PATCH with ONLY the unrelated /api/mcp/ prefix still succeeds (no regression from the /api/providers/ ancestor check)", async () => { + process.env.JWT_SECRET = "test-jwt-secret-authz-bypass"; + process.env.INITIAL_PASSWORD = "initial-pass-cursor-t4b"; + await settingsDb.updateSettings({ requireLogin: true }); + const { ensurePersistentManagementPasswordHash } = + await import("../../../src/lib/auth/managementPassword.ts"); + await ensurePersistentManagementPasswordHash({ source: "test.bootstrap" }); + + const response = await settingsRoute.PATCH( + await makeManagementSessionRequest("http://localhost/api/settings", { + method: "PATCH", + body: { + localOnlyManageScopeBypassPrefixes: ["/api/mcp/"], + currentPassword: "initial-pass-cursor-t4b", + }, + }) + ); + + assert.equal(response.status, 200); + const settings = await settingsDb.getSettings(); + assert.deepEqual(settings.localOnlyManageScopeBypassPrefixes, ["/api/mcp/"]); +}); + // ─── Defence-in-depth: snapshot mutation alone cannot grant spawn bypass ─ test("Defence-in-depth: even if a malformed snapshot lists /api/cli-tools/runtime/, the runtime predicate rejects it", async () => {