Files
OmniRoute/open-sse/utils/cursorImages.ts
payne 0594af6a6c feat(cursor): vision (image_url) input + tool-commit/output-constraint enhancements (#3104)
* feat(cursor): vision (image_url) input + tool-commit/output-constraint enhancements

Add image/vision input to the Cursor provider's agent.v1 endpoint, plus the
supporting prompt-engineering and resilience work developed alongside it.

Vision input
- Decode OpenAI `image_url` parts (base64 `data:` URIs and remote `http(s)` URLs)
  and inline them as `SelectedContext.selected_images[]` — field numbers pinned
  from the cursor-agent agent.v1 protobuf descriptor (SelectedImage.data oneof,
  uuid, optional Dimension, mime_type). Cross-checked against composer-api's shape.
- New `resolveCursorImages` helper: SSRF-guarded remote fetches via the repo's
  canonical `parseAndValidatePublicUrl` (always public-only for client URLs),
  <=1 MiB per image (pre-decode + streaming cap), `image/*` enforced, max 12
  images, sanitized `CursorImageError` (no stack/path leakage).
- `openai-to-cursor` translator now preserves `image_url` parts instead of
  dropping them; executor `buildRequest` resolves images and attaches them to
  the user turn. The no-image path is byte-identical to before (test-asserted).

Supporting cursor enhancements
- Tool-commit directive (raises composer-2.5 tool-call rate ~53% -> ~88%),
  `tool_choice` none/required/specific handling, and output constraints
  (`response_format` / `max_tokens` / `stop` surfaced as prompt instructions).
- `cursorSessionManager`: clear pending tool-call mappings on session close.
- `cursorVersionDetector`: export `FALLBACK_VERSION` as a single source of truth.

Tests & docs
- New unit suite for the image encoder + resolver (field layout, byte-identical
  no-image path, SSRF / oversize / bad-base64 / too-many rejections, sanitized
  error body), translator image-preservation tests, and live e2e tests
  (base64 + remote URL, gated on `CURSOR_E2E_TOKEN`).
- Documented `CURSOR_TOOL_DIRECTIVE` and `CURSOR_IMAGE_FETCH_TIMEOUT_MS` in
  `.env.example` and `docs/reference/ENVIRONMENT.md`.

* fix(cursor): address review — redirect SSRF, large-payload guard, stream OOM, case/NaN nits

Resolves the gemini-code-assist review on #3104:
- SSRF via redirect (critical): fetchImageBytes now uses redirect:"manual" and
  re-validates every hop through parseAndValidatePublicUrl, so a public URL can't
  30x-redirect to a private/link-local address. Bounded to 3 redirects.
- Large data URL (high): reject on raw payload length before the whitespace-strip
  regex, so an oversized data URL can't burn CPU.
- Stream read (high): readCapped consumes the body as an async iterable (Node
  Readable + Web Streams) or via getReader, capping mid-read; uncapped
  arrayBuffer() is only a last resort.
- data: scheme (medium): match case-insensitively (RFC 2397) while preserving the
  original payload.
- NaN timeouts (medium): CURSOR_IMAGE_FETCH_TIMEOUT_MS and CURSOR_STREAM_TIMEOUT_MS
  fall back to defaults when the env value isn't a positive integer.

Adds tests: redirect-to-private blocked, redirect-to-public followed, too-many-
redirects rejected, uppercase DATA: accepted.

* fix(cursor): defend image fetch against DNS-rebinding SSRF

Address the @codex review on #3104: parseAndValidatePublicUrl only checks the
hostname string, so a public-looking host that (re)resolves to a private /
link-local / metadata IP would still be fetched. Each hop now resolves the host
via dns.lookup({all:true}) and rejects if ANY answer is private (isPrivateHost),
before connecting. IP literals are skipped (already validated by the URL guard).

This narrows but doesn't fully close the TOCTOU window vs fetch's own
resolution; a connection-time IP filter on the shared outbound guard would
close it for every caller. Adds unit tests for the IP gate and a mocked
DNS-rebinding case (public host -> 127.0.0.1, fetch never reached).
2026-06-03 18:24:41 -03:00

355 lines
14 KiB
TypeScript

/**
* Image resolution + security for Cursor vision input.
*
* Turns OpenAI `image_url` parts (base64 `data:` URIs or remote `http(s)`
* URLs) into decoded bytes ready to inline into a cursor SelectedImage
* (see ../utils/cursorAgentProtobuf.ts::encodeSelectedImageBody).
*
* Security (OmniRoute hard rules):
* - SSRF: remote fetches go through the repo's canonical outbound guard
* (`parseAndValidatePublicUrl`), which rejects non-http(s) schemes,
* embedded credentials, localhost, link-local, private/CGNAT ranges, and
* cloud-metadata hostnames. Client-supplied image URLs are always held to
* the strict public-only policy (never gated by the private-URL toggle that
* admin-configured provider URLs use).
* - Size cap: each image must decode to <= 1 MiB (matches composer-api).
* Enforced both before base64 decode (cheap pre-check) and while streaming
* a remote body (so a hostile server can't stream gigabytes).
* - Content type: data URIs and URL responses must be `image/*`.
* - Errors throw `CursorImageError` with a clean, path-free message; the
* executor routes it through the sanitized 400 path (hard rule #12).
*/
import crypto from "node:crypto";
import dns from "node:dns";
import { isIP } from "node:net";
import {
parseAndValidatePublicUrl,
isPrivateHost,
OutboundUrlGuardError,
} from "@/shared/network/outboundUrlGuard";
import type { EncodedImage } from "./cursorAgentProtobuf.ts";
// 1 MiB per image — matches composer-api's MAX_CURSOR_IMAGE_BYTES. Large
// enough for a typical screenshot, small enough to bound request size and
// memory.
export const MAX_CURSOR_IMAGE_BYTES = 1024 * 1024;
// Upper bound on the number of images per request. Each image triggers (at
// most) one remote fetch, so an unbounded count is a DoS vector; 12 is well
// above any realistic vision prompt.
export const MAX_CURSOR_IMAGES = 12;
// Wall-clock cap for a single remote image fetch. A malformed env value
// (NaN / non-positive) falls back to the default rather than breaking setTimeout.
const IMAGE_FETCH_TIMEOUT_MS = (() => {
const parsed = parseInt(process.env.CURSOR_IMAGE_FETCH_TIMEOUT_MS || "15000", 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : 15000;
})();
// Bound on how many redirects fetchImageBytes will follow (each re-validated
// against the SSRF guard before the next hop).
const MAX_IMAGE_REDIRECTS = 3;
/**
* A 400-class error carrying a clean, non-sensitive message. The executor
* catches it and emits a sanitized error response.
*/
export class CursorImageError extends Error {
status: number;
constructor(message: string, status = 400) {
super(message);
this.name = "CursorImageError";
this.status = status;
}
}
function decodeDataUrl(url: string): { data: Buffer; mimeType: string } {
// data:[<mediatype>][;base64],<data>
const comma = url.indexOf(",");
if (comma < 0) {
throw new CursorImageError("Image data URL is malformed.");
}
const header = url.slice(5, comma); // strip leading "data:"
const payload = url.slice(comma + 1);
const isBase64 = /;base64/i.test(header);
const mimeType = (header.split(";")[0] || "").trim().toLowerCase() || "application/octet-stream";
if (!mimeType.startsWith("image/")) {
throw new CursorImageError("Image data URL must have an image/* media type.");
}
if (!isBase64) {
// Non-base64 data URLs (percent-encoded) are not a real image transport;
// reject rather than guess.
throw new CursorImageError("Image data URL must be base64-encoded.");
}
// Reject on the raw payload length BEFORE the regex/normalize pass, so an
// arbitrarily large data URL can't burn CPU on the whitespace strip. Base64
// expands ~4:3, so 2x the byte cap is a safe upper bound on the encoded text.
if (payload.length > MAX_CURSOR_IMAGE_BYTES * 2) {
throw new CursorImageError("Image input is too large (max 1 MiB). Resize and retry.");
}
const normalized = payload.replace(/\s/g, "");
// Cheap pre-check: 4 base64 chars -> 3 bytes. Reject obviously oversized
// payloads before allocating the decode buffer.
if (Math.floor((normalized.length * 3) / 4) > MAX_CURSOR_IMAGE_BYTES) {
throw new CursorImageError("Image input is too large (max 1 MiB). Resize and retry.");
}
let data: Buffer;
try {
data = Buffer.from(normalized, "base64");
} catch {
throw new CursorImageError("Image data URL contains invalid base64 data.");
}
// Buffer.from(base64) silently drops invalid trailing chars; guard against a
// payload that decoded to nothing despite being non-empty.
if (normalized.length > 0 && data.length === 0) {
throw new CursorImageError("Image data URL contains invalid base64 data.");
}
return { data, mimeType };
}
// Validate a URL through the SSRF guard, mapping guard errors to clean,
// non-sensitive CursorImageErrors (no URL echoed back).
function validatePublicImageUrl(url: string): URL {
try {
return parseAndValidatePublicUrl(url);
} catch (err) {
if (err instanceof OutboundUrlGuardError) {
throw new CursorImageError(
err.code === "OUTBOUND_URL_INVALID"
? "Image URL is invalid or uses an unsupported scheme."
: "Image URL points to a blocked address."
);
}
throw new CursorImageError("Image URL is invalid.");
}
}
/**
* Throw if any of the resolved addresses falls in a private / link-local /
* loopback / CGNAT / metadata range. Exported for unit testing the IP gate
* without going through DNS.
*/
export function assertResolvedAddressesPublic(addresses: string[]): void {
for (const addr of addresses) {
if (isPrivateHost(addr)) {
throw new CursorImageError("Image URL points to a blocked address.");
}
}
}
/**
* Defence-in-depth against DNS-rebinding SSRF: `parseAndValidatePublicUrl`
* only checks the hostname *string*, so a public-looking host that resolves to
* a private/metadata IP would otherwise be fetched. Resolve the host and
* reject if ANY answer is private. IP literals are skipped (already validated
* by the guard above). This narrows — but doesn't fully eliminate — the
* TOCTOU window between our resolution and fetch's own; a connection-time IP
* filter (e.g. ssrf-req-filter) on the shared outbound guard would close it
* for every caller.
*/
async function assertHostnameResolvesPublic(hostname: string): Promise<void> {
const bare =
hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
if (isIP(bare)) return; // IP literal — already checked by the URL guard.
let resolved: Array<{ address: string }>;
try {
resolved = await dns.promises.lookup(bare, { all: true });
} catch {
throw new CursorImageError("Image URL host could not be resolved.");
}
assertResolvedAddressesPublic(resolved.map((r) => r.address));
}
async function fetchImageBytes(url: string): Promise<{ data: Buffer; mimeType: string }> {
// Follow redirects MANUALLY and re-validate every hop through the SSRF guard.
// `fetch` follows redirects by default, so validating only the initial URL
// would let a public host 30x-redirect to a private/link-local address and
// bypass the guard. Each Location is resolved + re-checked before we fetch it.
let currentUrl = url;
for (let hop = 0; hop <= MAX_IMAGE_REDIRECTS; hop++) {
const parsed = validatePublicImageUrl(currentUrl);
// Resolve + IP-check the host (DNS-rebinding defence) before connecting.
await assertHostnameResolvesPublic(parsed.hostname);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), IMAGE_FETCH_TIMEOUT_MS);
let response: Response;
try {
response = await fetch(parsed.toString(), {
method: "GET",
signal: controller.signal,
redirect: "manual",
});
} catch {
clearTimeout(timer);
throw new CursorImageError("Could not fetch the image URL.");
}
try {
// Manual redirect: resolve Location against the current URL and loop so
// the next hop is re-validated by the SSRF guard.
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get("location");
if (!location) {
throw new CursorImageError("Image URL redirect is missing a destination.");
}
try {
currentUrl = new URL(location, parsed.toString()).toString();
} catch {
throw new CursorImageError("Image URL redirect destination is invalid.");
}
continue;
}
if (!response.ok) {
throw new CursorImageError(`Could not fetch the image URL (status ${response.status}).`);
}
const contentType = (response.headers.get("content-type") || "").toLowerCase();
const mimeType = contentType.split(";")[0].trim();
if (!mimeType.startsWith("image/")) {
throw new CursorImageError("Image URL did not return an image content type.");
}
// Reject early on an oversized Content-Length, then still cap during read
// (the header is advisory / may be absent).
const declaredLen = Number(response.headers.get("content-length") || "0");
if (Number.isFinite(declaredLen) && declaredLen > MAX_CURSOR_IMAGE_BYTES) {
throw new CursorImageError("Image input is too large (max 1 MiB). Resize and retry.");
}
const data = await readCapped(response, MAX_CURSOR_IMAGE_BYTES);
return { data, mimeType };
} finally {
clearTimeout(timer);
}
}
throw new CursorImageError("Image URL has too many redirects.");
}
/**
* Read a fetch Response body into a Buffer, aborting as soon as the
* accumulated size exceeds `cap`. Consumes the body incrementally — as an
* async iterable (Node Readable streams and Web Streams both support this) or
* via a web ReadableStream reader — so an oversized body is rejected mid-read
* rather than fully buffered. The uncapped arrayBuffer() path is only a last
* resort for exotic body shapes, and is still cap-checked afterwards.
*/
async function readCapped(response: Response, cap: number): Promise<Buffer> {
const body = response.body as
| (AsyncIterable<Uint8Array> & { getReader?: () => ReadableStreamDefaultReader<Uint8Array> })
| null;
if (!body) {
return Buffer.alloc(0);
}
const chunks: Buffer[] = [];
let total = 0;
const pushCapped = (chunk: Uint8Array) => {
total += chunk.byteLength;
if (total > cap) {
throw new CursorImageError("Image input is too large (max 1 MiB). Resize and retry.");
}
chunks.push(Buffer.from(chunk));
};
// Preferred: async iteration (works for Node Readable + Web Streams).
if (typeof (body as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === "function") {
for await (const chunk of body) {
pushCapped(chunk as Uint8Array);
}
return Buffer.concat(chunks, total);
}
// Fallback: web ReadableStream reader.
if (typeof body.getReader === "function") {
const reader = body.getReader();
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (value) pushCapped(value);
}
} finally {
try {
await reader.cancel();
} catch {
/* already closed */
}
}
return Buffer.concat(chunks, total);
}
// Last resort: buffer then cap-check (only exotic non-stream bodies).
const buf = Buffer.from(await response.arrayBuffer());
if (buf.length > cap) {
throw new CursorImageError("Image input is too large (max 1 MiB). Resize and retry.");
}
return buf;
}
/**
* Resolve OpenAI `image_url` URLs (data: or http(s):) into EncodedImage[]
* ready to inline into a cursor request. Each image gets a stable random uuid.
* Throws CursorImageError (clean message, sanitizable) on any invalid /
* oversized / blocked input.
*/
export async function resolveCursorImages(imageUrls: string[]): Promise<EncodedImage[]> {
if (imageUrls.length > MAX_CURSOR_IMAGES) {
throw new CursorImageError(
`Too many images in one request (max ${MAX_CURSOR_IMAGES}).`
);
}
const out: EncodedImage[] = [];
for (const url of imageUrls) {
if (typeof url !== "string" || !url) {
throw new CursorImageError("Image URL is missing.");
}
// The data: scheme is case-insensitive (RFC 2397); match it that way but
// pass the original (un-lowercased) url so the base64 payload is preserved.
const { data, mimeType } = url.toLowerCase().startsWith("data:")
? decodeDataUrl(url)
: await fetchImageBytes(url);
if (!data.length) {
throw new CursorImageError("Image input is empty.");
}
if (data.length > MAX_CURSOR_IMAGE_BYTES) {
throw new CursorImageError("Image input is too large (max 1 MiB). Resize and retry.");
}
out.push({ data, mimeType, uuid: crypto.randomUUID() });
}
return out;
}
/**
* Extract image_url URLs from an OpenAI-shaped message content array.
* Returns the raw url strings (data: or http(s):) in order. Non-image parts
* are ignored. A plain string content has no images.
*/
export function extractImageUrls(
content: unknown
): string[] {
if (!Array.isArray(content)) return [];
const urls: string[] = [];
for (const part of content) {
if (
part &&
typeof part === "object" &&
(part as { type?: unknown }).type === "image_url"
) {
const imageUrl = (part as { image_url?: unknown }).image_url;
if (typeof imageUrl === "string") {
urls.push(imageUrl);
} else if (
imageUrl &&
typeof imageUrl === "object" &&
typeof (imageUrl as { url?: unknown }).url === "string"
) {
urls.push((imageUrl as { url: string }).url);
}
}
}
return urls;
}