From 09fcf7bc5c35c66b7def4c9bc64b325f7f0f6221 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:12:58 +0900 Subject: [PATCH] fix(cursor): bound local installer manifests --- src/integrations/cursor-local-installer.ts | 31 ++++++- structure/clients/integrations.md | 3 +- .../cursor/cursor-local-installer.test.ts | 85 +++++++++++++++++++ 3 files changed, 116 insertions(+), 3 deletions(-) diff --git a/src/integrations/cursor-local-installer.ts b/src/integrations/cursor-local-installer.ts index 996da4b61d5..c454504da7d 100644 --- a/src/integrations/cursor-local-installer.ts +++ b/src/integrations/cursor-local-installer.ts @@ -17,6 +17,9 @@ const DEFAULT_UPDATE_HOST = "https://api2.cursor.sh"; const UPDATE_MANIFEST_TIMEOUT_MS = 4_000; +const UPDATE_MANIFEST_MAX_BYTES = 64 * 1024; +const UPDATE_MANIFEST_MAX_VERSION_LENGTH = 256; +const UPDATE_MANIFEST_MAX_URL_LENGTH = 4 * 1024; const CACHE_TTL_OK_MS = 30 * 60_000; const CACHE_TTL_FAILED_MS = 5 * 60_000; @@ -63,7 +66,29 @@ export function realCursorLocalHintDeps(): CursorLocalHintDeps { fetchJson: async (url, timeoutMs) => { const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); if (!response.ok) throw new Error(`HTTP ${response.status}`); - return response.json(); + if (!response.body) throw new Error("Missing response body"); + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > UPDATE_MANIFEST_MAX_BYTES) { + // Best-effort discard: awaiting cancel() could hang on a stalled source + // even though AbortSignal.timeout already bounds the request itself. + void reader.cancel().catch(() => {}); + throw new Error("Update manifest exceeds byte limit"); + } + chunks.push(value); + } + const body = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return JSON.parse(new TextDecoder().decode(body)); }, }; } @@ -86,7 +111,8 @@ export function platformForHost(os: string, arch: string): CursorLocalPlatform | function parseManifest(raw: unknown): { version: string; url: string } | null { if (!raw || typeof raw !== "object") return null; const record = raw as CursorLocalManifest; - if (typeof record.url !== "string" || !/^https:\/\/downloads\.cursor\.com\/local-mode\//.test(record.url)) { + if (typeof record.url !== "string" || record.url.length > UPDATE_MANIFEST_MAX_URL_LENGTH + || !/^https:\/\/downloads\.cursor\.com\/local-mode\//.test(record.url)) { return null; } // Windows and Linux manifests carry `version`/`productVersion`; the Darwin ones return only @@ -94,6 +120,7 @@ function parseManifest(raw: unknown): { version: string; url: string } | null { const candidate = typeof record.version === "string" ? record.version : typeof record.productVersion === "string" ? record.productVersion : typeof record.name === "string" ? record.name : ""; + if (candidate.length > UPDATE_MANIFEST_MAX_VERSION_LENGTH) return null; const version = candidate.trim(); if (version === "") return null; // The Linux channel advertises the AppImage's zsync delta metadata (what the in-app updater diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index 117a1fc8b67..790b0b5796b 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -78,7 +78,8 @@ Cursor's `cursor-local` update channel advertises for the host platform and arch `x64` and `arm64` on Windows, macOS and Linux map; any other host resolves to `unsupported-platform` with no request) (`/updates/api/update//cursor-local/0.0.0/manual-check/stable`, 4 s timeout). -Only a `https://downloads.cursor.com/local-mode/` URL with a version is accepted (a Linux +The decoded manifest is capped at 64 KiB before JSON parsing. Only a bounded +`https://downloads.cursor.com/local-mode/` URL with a bounded version is accepted (a Linux `.AppImage.zsync` delta-metadata URL is mapped to its sibling `.AppImage`); anything else resolves to `available: false` with reason `unreachable` or `unusable-response`, and nothing is requested when Private Inference is already installed or no regular install exists. The module never diff --git a/tests/providers/cursor/cursor-local-installer.test.ts b/tests/providers/cursor/cursor-local-installer.test.ts index 3d3578a3e2a..5178aeed01d 100644 --- a/tests/providers/cursor/cursor-local-installer.test.ts +++ b/tests/providers/cursor/cursor-local-installer.test.ts @@ -59,6 +59,91 @@ describe("buildCursorLocalInstallerHint", () => { expect(hint).toEqual({ available: false, url: null, version: null, reason: "unusable-response" }); }); + test("oversized manifest fields are rejected before they can be cached", async () => { + for (const manifest of [ + { version: "v".repeat(257), url: REPORTED_INSTALLER }, + { version: "3.21.18", url: `https://downloads.cursor.com/local-mode/${"x".repeat(4096)}` }, + ]) { + resetCursorLocalInstallerCacheForTests(); + const hint = await buildCursorLocalInstallerHint( + { regularInstalled: true, privateInferenceInstalled: false }, + depsWith(manifest), + ); + expect(hint).toEqual({ available: false, url: null, version: null, reason: "unusable-response" }); + } + }); + + test("real deps stop reading a decoded response above the manifest byte limit", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => Promise.resolve(new Response(JSON.stringify({ + version: "v".repeat(70 * 1024), + url: REPORTED_INSTALLER, + })))) as typeof fetch; + try { + const hint = await buildCursorLocalInstallerHint( + { regularInstalled: true, privateInferenceInstalled: false }, + { ...realCursorLocalHintDeps(), platform: "win32", arch: "x64" }, + ); + expect(hint).toEqual({ available: false, url: null, version: null, reason: "unreachable" }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("oversize rejection does not await a never-settling stream cancellation", async () => { + const originalFetch = globalThis.fetch; + let cancelled = 0; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(32 * 1024)); + controller.enqueue(new Uint8Array(32 * 1024 + 1)); + }, + cancel() { + cancelled += 1; + return new Promise(() => {}); + }, + }); + globalThis.fetch = (() => Promise.resolve(new Response(body))) as typeof fetch; + let timer: ReturnType | undefined; + try { + const outcome = await Promise.race([ + realCursorLocalHintDeps().fetchJson(MANIFEST_URL, 4_000).then( + () => ({ kind: "resolved" }), + error => ({ kind: "rejected", message: String(error.message) }), + ), + new Promise(resolve => { timer = setTimeout(() => resolve({ kind: "timeout" }), 1_000); }), + ]); + expect(outcome).toEqual({ kind: "rejected", message: "Update manifest exceeds byte limit" }); + expect(cancelled).toBe(1); + } finally { + clearTimeout(timer); + globalThis.fetch = originalFetch; + } + }); + + test.each([1_024, 64 * 1024])("valid multichunk manifest at %i bytes is decoded", async size => { + const originalFetch = globalThis.fetch; + const manifest = { version: "3.21.18", url: REPORTED_INSTALLER, padding: "" }; + const encoder = new TextEncoder(); + manifest.padding = "x".repeat(size - encoder.encode(JSON.stringify(manifest)).byteLength); + const encoded = encoder.encode(JSON.stringify(manifest)); + expect(encoded.byteLength).toBe(size); + const body = new ReadableStream({ + start(controller) { + for (let offset = 0; offset < encoded.byteLength; offset += 512) { + controller.enqueue(encoded.slice(offset, offset + 512)); + } + controller.close(); + }, + }); + globalThis.fetch = (() => Promise.resolve(new Response(body))) as typeof fetch; + try { + expect(await realCursorLocalHintDeps().fetchJson(MANIFEST_URL, 4_000)).toEqual(manifest); + } finally { + globalThis.fetch = originalFetch; + } + }); + test("no regular install resolves nothing without any network call", async () => { const hint = await buildCursorLocalInstallerHint( { regularInstalled: false, privateInferenceInstalled: false },