fix(sse): loopback/LAN-gate the cursor-agent-image spawn path (Hard Rules #15/#17)

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>
This commit is contained in:
valvesss
2026-07-25 08:31:54 -03:00
committed by Markus Hartung
parent b5ef904b94
commit 31653352cb
5 changed files with 124 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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