From 31653352cb7620d5c3c21b125d8536beeeb52e8f Mon Sep 17 00:00:00 2001 From: valvesss Date: Sat, 25 Jul 2026 08:31:54 -0300 Subject: [PATCH] fix(sse): loopback/LAN-gate the cursor-agent-image spawn path (Hard Rules #15/#17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleCursorAgentImageGeneration spawns the Cursor `agent` CLI, but POST /v1/images/generations is shared by ~40 non-spawning image providers that remote callers legitimately use, so the whole route can't be classified LOCAL_ONLY without breaking them. Instead the handler now rejects before any credential lookup or spawn unless the trusted AUTHZ_HEADER_PEER_LOCALITY verdict (stamped by the authz pipeline from the real TCP peer, never the spoofable Host header) is "loopback" or "lan" — mirroring the policy every other LOCAL_ONLY route already gets. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- docs/providers/CURSOR_IMAGE.md | 24 ++++++++++ open-sse/handlers/imageGeneration.ts | 6 +++ .../providers/cursorAgentImage.ts | 41 +++++++++++++++++ src/app/api/v1/images/generations/route.ts | 7 +++ tests/unit/cursor-agent-image.test.ts | 46 +++++++++++++++++++ 5 files changed, 124 insertions(+) diff --git a/docs/providers/CURSOR_IMAGE.md b/docs/providers/CURSOR_IMAGE.md index 643c69c689..a620c4de79 100644 --- a/docs/providers/CURSOR_IMAGE.md +++ b/docs/providers/CURSOR_IMAGE.md @@ -19,6 +19,30 @@ OmniRoute exposes Cursor plan **image generation** on `POST /v1/images/generatio Cursor chat in OmniRoute uses `agent.v1.AgentService/Run` (protobuf). That path **rejects** built-in client tools (shell, write, …). Image generation is a Cursor-native tool executed by the **`agent` CLI** against the seat. The image handler therefore spawns `agent` with a locked prompt and a per-request temp workspace (same shape as community seat bridges), then returns OpenAI-compatible `b64_json`. +## Access restriction (Hard Rules #15 + #17) + +This is the only `IMAGE_PROVIDERS` format that spawns a child process (the `agent` +binary). Because `POST /v1/images/generations` is shared by ~40 other, non-spawning +image providers that remote callers legitimately use, the whole route is **not** +classified `LOCAL_ONLY` — instead `handleCursorAgentImageGeneration` enforces its own +gate using the trusted `AUTHZ_HEADER_PEER_LOCALITY` verdict the authz pipeline stamps +on every request (from the real TCP peer, never the spoofable `Host` header): only +`loopback` and `lan` callers may reach the spawn; everything else (including a leaked +API key replayed over a public tunnel) gets `403` before any credential lookup or +process spawn happens. See `src/server/authz/policies/management.ts` for the same +policy applied to the rest of the `LOCAL_ONLY` tier. + +## Concurrency gate is module-level (single-instance limitation) + +`CURSOR_IMG_MAX_CONCURRENT` is enforced by an in-memory counter/queue scoped to the +Node module instance (`open-sse/handlers/imageGeneration/providers/cursorAgentImage.ts`). +It correctly limits concurrent `agent` spawns within one OmniRoute process, but does +**not** coordinate across multiple processes/instances sharing the same Cursor seat +(e.g. a multi-replica deployment) — each instance enforces its own independent limit. +For a single-instance deployment (the default) this is exact; horizontally scaled +deployments should keep `CURSOR_IMG_MAX_CONCURRENT` conservative per instance or route +Cursor image traffic to a single instance. + ## Requirements 1. A connected Cursor account in the dashboard (OAuth or `crsr_…` API key). diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index 05cec84da9..9447a3dffe 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -278,6 +278,10 @@ const FAL_PRESET_SIZES = { * @param {object} options.credentials - Provider credentials { apiKey, accessToken } * @param {object} options.log - Logger * @param {string} [options.resolvedProvider] - Pre-resolved provider ID (from route layer custom model resolution) + * @param {string|null} [options.peerLocality] - Trusted "loopback"|"lan"|"remote" verdict + * forwarded from `AUTHZ_HEADER_PEER_LOCALITY` (src/server/authz/headers.ts). Only consumed by + * spawn-capable providers (e.g. cursor-agent-image) to enforce Hard Rules #15/#17 without + * loopback-gating the whole route for every non-spawning image provider. */ export async function handleImageGeneration({ body, @@ -286,6 +290,7 @@ export async function handleImageGeneration({ resolvedProvider = null, signal = null, clientHeaders = null, + peerLocality = null, }) { let provider, model; @@ -504,6 +509,7 @@ export async function handleImageGeneration({ body, credentials, log, + peerLocality, }); } diff --git a/open-sse/handlers/imageGeneration/providers/cursorAgentImage.ts b/open-sse/handlers/imageGeneration/providers/cursorAgentImage.ts index a3c1550e4b..a05b7ef854 100644 --- a/open-sse/handlers/imageGeneration/providers/cursorAgentImage.ts +++ b/open-sse/handlers/imageGeneration/providers/cursorAgentImage.ts @@ -57,6 +57,22 @@ export function resolveCursorImageModel(candidate: unknown): string { const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); const JPEG_MAGIC = Buffer.from([0xff, 0xd8, 0xff]); +/** + * Localities allowed to trigger the `agent` binary spawn below (Hard Rules + * #15 + #17). `/v1/images/generations` is a normal remote-reachable inference + * route shared by ~40 image providers that only proxy HTTP — the ONLY branch + * here that spawns a child process is this one, so the whole route cannot be + * classified in `LOCAL_ONLY_API_PREFIXES` (routeGuard.ts) without blocking + * every other, non-spawning image provider for remote callers. Instead this + * handler enforces its OWN loopback/LAN gate using the trusted locality + * verdict the authz pipeline already stamps on every request + * (`AUTHZ_HEADER_PEER_LOCALITY`, src/server/authz/headers.ts, computed from + * the real TCP peer IP — never the spoofable Host header). Mirrors the + * loopback-or-private-LAN policy `managementPolicy` applies to every other + * LOCAL_ONLY route (src/server/authz/policies/management.ts). + */ +const SPAWN_ALLOWED_LOCALITIES = new Set(["loopback", "lan"]); + /** Locked instruction — ingress callers can only trigger image gen, never a shell. */ export function buildCursorAgentImagePrompt(userPrompt: string, outPath: string, size?: unknown): string { const sizeHint = @@ -331,6 +347,7 @@ export async function handleCursorAgentImageGeneration({ credentials, log, spawnImpl, + peerLocality, }: { model: string; provider: string; @@ -345,8 +362,32 @@ export async function handleCursorAgentImageGeneration({ log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; /** Test seam — defaults to node:child_process.spawn */ spawnImpl?: typeof spawn; + /** + * Trusted locality verdict ("loopback" | "lan" | "remote") forwarded by the + * route layer from `AUTHZ_HEADER_PEER_LOCALITY` (stamped by the authz + * pipeline from the real TCP peer, never the spoofable Host header). Absent + * or unrecognized → fail closed (treated as "remote"). + */ + peerLocality?: string | null; }) { const startTime = Date.now(); + + // Hard Rules #15 + #17: reject before doing ANY other work — credential + // lookup, prompt validation, and the `agent` binary spawn itself must never + // run for a non-loopback/non-LAN caller. A leaked API key tunneled from the + // public internet must not be able to trigger a child-process spawn on the + // OmniRoute host. + if (!peerLocality || !SPAWN_ALLOWED_LOCALITIES.has(peerLocality)) { + return saveImageErrorResult({ + provider, + model, + status: 403, + startTime, + error: + "Cursor Agent image generation spawns a local process and is only available from localhost or the private LAN OmniRoute runs on.", + }); + } + const prompt = typeof body.prompt === "string" ? body.prompt.trim() : ""; if (!prompt) { return saveImageErrorResult({ diff --git a/src/app/api/v1/images/generations/route.ts b/src/app/api/v1/images/generations/route.ts index 41916a55e6..aa228a4f75 100644 --- a/src/app/api/v1/images/generations/route.ts +++ b/src/app/api/v1/images/generations/route.ts @@ -31,6 +31,7 @@ import { getSpecialtyModelsResponse } from "@/app/api/v1/_shared/specialtyCatalo import { enforceClientApiRouteAuth } from "@/shared/utils/clientApiRouteAuth"; import { runWithCallLogApiKeyContext } from "@/lib/usage/callLogApiKeyContext"; import { executeImageWithCredentialFallback } from "@/sse/services/imageCredentialRetry"; +import { AUTHZ_HEADER_PEER_LOCALITY } from "@/server/authz/headers"; export const dynamic = "force-dynamic"; @@ -290,6 +291,12 @@ async function postHandler(request, context) { ...(isCustomModel && { resolvedProvider: provider }), signal: request.signal, clientHeaders: publicBaseUrlHeaders(request.headers), + // Trusted "loopback"|"lan"|"remote" verdict stamped by the authz + // pipeline from the real TCP peer (never the spoofable Host + // header). Only the spawn-capable cursor-agent-image provider + // consumes this (Hard Rules #15 + #17) — every other image + // provider ignores it. + peerLocality: request.headers.get(AUTHZ_HEADER_PEER_LOCALITY), }) ); diff --git a/tests/unit/cursor-agent-image.test.ts b/tests/unit/cursor-agent-image.test.ts index d3c80caeb2..c4bd377217 100644 --- a/tests/unit/cursor-agent-image.test.ts +++ b/tests/unit/cursor-agent-image.test.ts @@ -71,6 +71,7 @@ test("handleCursorAgentImageGeneration rejects empty prompt and missing credenti providerConfig: { baseUrl: "agent://cursor-agent" }, body: { prompt: " " }, credentials: { accessToken: "crsr_x" }, + peerLocality: "loopback", }); assert.equal(noPrompt.success, false); assert.equal(noPrompt.status, 400); @@ -81,6 +82,7 @@ test("handleCursorAgentImageGeneration rejects empty prompt and missing credenti providerConfig: { baseUrl: "agent://cursor-agent" }, body: { prompt: "hi" }, credentials: {}, + peerLocality: "loopback", }); assert.equal(noCreds.success, false); assert.equal(noCreds.status, 401); @@ -97,12 +99,55 @@ test("handleCursorAgentImageGeneration returns 501 when agentBin path is missing accessToken: "crsr_test", providerSpecificData: { agentBin: "/nonexistent/cursor-agent-bin" }, }, + peerLocality: "loopback", }); assert.equal(result.success, false); assert.equal(result.status, 501); assert.match(String(result.error), /CURSOR_AGENT_BIN|agentBin/i); }); +// ─── Hard Rules #15 + #17: spawn-capable providers must loopback/LAN-gate ─── + +test("handleCursorAgentImageGeneration rejects a non-loopback/non-LAN caller BEFORE spawning", async () => { + __resetCursorAgentImageConcurrencyForTests(); + let spawnCalled = false; + const spyingSpawn = (() => { + spawnCalled = true; + throw new Error("spawn must never be invoked for a remote caller"); + }) as unknown as typeof import("node:child_process").spawn; + + const result = await handleCursorAgentImageGeneration({ + model: "auto", + provider: "cursor", + providerConfig: { baseUrl: "agent://cursor-agent" }, + body: { prompt: "a lantern in fog" }, + credentials: { + accessToken: "crsr_test", + providerSpecificData: { agentBin: process.execPath }, + }, + spawnImpl: spyingSpawn, + peerLocality: "remote", + }); + + assert.equal(spawnCalled, false, "spawn must not run for a rejected non-local caller"); + assert.equal(result.success, false); + assert.equal(result.status, 403); + assert.match(String(result.error), /localhost|LAN/i); +}); + +test("handleCursorAgentImageGeneration rejects when peerLocality is missing (fail closed)", async () => { + __resetCursorAgentImageConcurrencyForTests(); + const result = await handleCursorAgentImageGeneration({ + model: "auto", + provider: "cursor", + providerConfig: { baseUrl: "agent://cursor-agent" }, + body: { prompt: "a lantern in fog" }, + credentials: { accessToken: "crsr_test" }, + }); + assert.equal(result.success, false); + assert.equal(result.status, 403); +}); + /** * Minimal fake `spawn` that writes a tiny PNG to the out path embedded in the * prompt and exits 0 — exercises the success path without a real Cursor Agent. @@ -153,6 +198,7 @@ test("handleCursorAgentImageGeneration returns b64_json via injectable spawn", a providerSpecificData: { agentBin: process.execPath }, }, spawnImpl: fakeSpawn, + peerLocality: "loopback", }); assert.equal(result.success, true);