mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-20 22:22:57 +03:00
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:
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user