From bdc91bb1c56ff0c5982f29665e6fcecafe620a13 Mon Sep 17 00:00:00 2001 From: "CK @ iRonin.IT" Date: Sat, 23 May 2026 09:38:11 -0400 Subject: [PATCH] fix: evict expired entries from cursorIdentityCache The cache sets a 1-hour TTL but never removes expired entries, causing unbounded growth in long-lived Worker isolates when API keys are rotated. - Add lazy eviction on cache miss (delete expired entry when found) - Add periodic sweep on each set() to evict all other expired entries - Export evictExpiredCacheEntries and cursorIdentityCache for testing --- SECURITY-AUDIT.md | 349 ++++++++++++++++++++++++++++++++++++++++++ worker/cursor.test.ts | 21 +++ worker/cursor.ts | 15 +- 3 files changed, 384 insertions(+), 1 deletion(-) create mode 100644 SECURITY-AUDIT.md diff --git a/SECURITY-AUDIT.md b/SECURITY-AUDIT.md new file mode 100644 index 0000000..cf8a8ab --- /dev/null +++ b/SECURITY-AUDIT.md @@ -0,0 +1,349 @@ +# Security Audit Report — composer-api + +**Date:** 2026-05-23 +**Scope:** `/Users/ironin/Work/Pi-Agent/composer-api` (commit on `main`) +**Type:** Full security review — supply chain, dependency audit, static code review + +--- + +## Executive Summary + +`composer-api` is a Cloudflare Workers application that proxies OpenAI-compatible requests to Cursor Composer. It consists of: + +- **Worker** (server-side): TypeScript, handles auth, request transformation, streaming SSE, D1 database, AES-GCM encryption of API keys +- **Client SPA** (browser-side): TypeScript, chat UI, localStorage session management +- **SDK proxy script** (local dev tool): Node.js HTTP server wrapping `@cursor/sdk` + +**Overall risk: MEDIUM-HIGH.** No critical RCE or credential leak in the worker itself, but there are real supply-chain vulnerabilities in dependencies, a wildcard CORS policy, missing security headers, an SSRF vector, and the SDK proxy script disables sandboxing. + +--- + +## 1. Supply Chain & Dependencies + +### 1.1 npm Audit — 14 vulnerabilities + +| Severity | Package | Chain | Notes | +|----------|---------|-------|-------| +| **HIGH** | `sqlite3` (5.0.0–5.1.7) | via `node-gyp` → `make-fetch-happen` → `cacache` → `tar` | `tar` has multiple path-traversal CVEs (GHSA-34x7-hfp2-rc4v, GHSA-8qq5-rm4j-mr97, GHSA-83g3-92jg-28cx, GHSA-qffp-2rhf-9h96, GHSA-9ppj-qmqm-q256, GHSA-r6q2-hw4h-h46w) | +| **HIGH** | `tar` (<7.5.7) | direct transitive dep | Arbitrary file creation/overwrite via hardlink path traversal (CVSS 8.2) | +| **HIGH** | `undici` (≤6.23.0) | via `@connectrpc/connect-node` → `@cursor/sdk` | Unbounded decompression chain → DoS (GHSA-g9mf-h72j-4rw9); HTTP request/response smuggling (GHSA-2mjp-6q6p-2qxm); WebSocket memory issues | +| **HIGH** | `@cursor/sdk` (1.0.13) | direct dependency | Depends on vulnerable `@connectrpc/connect-node` + `sqlite3`; **no fix available** without upstream fix | +| **MODERATE** | `ws` (8.0.0–8.20.0) | via `miniflare` → `@cloudflare/vite-plugin`, `wrangler` | Uninitialized memory disclosure (GHSA-58qx-3vcg-4xpx) | +| **LOW** | `@tootallnate/once` (<2.0.1) | via `http-proxy-agent` | Incorrect control flow scoping (GHSA-vpq2-c234-7xj6) | + +**Risk assessment:** The `tar` CVEs are **HIGH severity at build time only** — the worker never extracts tarballs. The `undici` CVEs affect the Node.js fetch runtime — also **build-time/dev-time only** since the deployed Cloudflare Worker uses its own fetch, not Node.js. `@cursor/sdk` vulnerabilities are dev-time only (the SDK proxy script). **None of these are exploitable in production on Cloudflare Workers**, but they matter for local development. + +**Remediation:** `npm audit fix` resolves most; `@cursor/sdk` needs upstream update. + +### 1.2 Install Scripts + +No `postinstall`, `preinstall`, or `prepare` scripts in `package.json`. No custom install scripts. ✅ + +### 1.3 Non-npm Resolutions + +No `overrides`, `resolutions`, or `pnpm.overrides` in package.json. Only two direct dependencies: `@cursor/sdk` (npm) and `lucide` (npm). ✅ + +--- + +## 2. Code Review Findings + +### FINDING 1 — Wildcard CORS on API Endpoints +**Severity: MEDIUM** +**File:** `worker/http.ts:5-9`, `worker/http.ts:13-20` +**Location:** `CORS_HEADERS`, `withCors()` + +```ts +const CORS_HEADERS = { + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET,POST,OPTIONS", + "access-control-allow-headers": "authorization,content-type,x-api-key,idempotency-key", + "access-control-max-age": "86400" +}; +``` + +Every response (including error responses) gets `Access-Control-Allow-Origin: *`. For an API that handles authentication tokens, this is permissive. An attacker's page could make authenticated requests to the API via CORS if the browser sends credentials (though `credentials: "include"` would be blocked by the lack of `Access-Control-Allow-Credentials: true`). + +**Risk:** Low in practice because the API uses Bearer tokens (not cookies), but still exposes all endpoints to cross-origin requests. The `/api/signup` endpoint that stores encrypted API keys is also CORS-open. + +**Recommendation:** Consider restricting `Access-Control-Allow-Origin` to known origins or removing CORS from authenticated endpoints. + +--- + +### FINDING 2 — No Security Response Headers +**Severity: MEDIUM** +**File:** `worker/http.ts`, all response constructors + +No security headers on any response: +- ❌ `Content-Security-Policy` +- ❌ `X-Content-Type-Options: nosniff` +- ❌ `X-Frame-Options` / `Frame-Options` +- ❌ `Strict-Transport-Security` +- ❌ `Referrer-Policy` +- ❌ `Permissions-Policy` + +**Risk:** The SPA (`src/`) renders HTML with `innerHTML` from server-sourced markdown. Without CSP, any future injection would execute freely. `X-Content-Type-Options: nosniff` would prevent MIME-type confusion attacks. + +**Recommendation:** Add at minimum `X-Content-Type-Options: nosniff` to all responses. Add CSP for the SPA frontend. + +--- + +### FINDING 3 — SSRF via Image URL Fetching +**Severity: MEDIUM** +**File:** `worker/cursor.ts:500-519` — `fetchImageBytes()` + +```ts +async function fetchImageBytes(url: string, deps: Deps): Promise { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new HttpError("Image URL is invalid.", ...); + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + throw new HttpError("Image URL must use http or https.", ...); + } + const response = await deps.fetch(parsed.toString(), { method: "GET" }); + // ... +} +``` + +Image URLs from user-controlled `image_url` fields are fetched server-side with no allowlist, no blocklist, no IP-range restrictions, no redirect-following limits. While the protocol check (`http:`/`https:`) blocks `file://` and `data://`, it does **not** block: +- Internal Cloudflare metadata URLs (`http://169.254.169.254/...`) +- Private IP ranges (`http://10.0.0.1/...`, `http://127.0.0.1:8787/...`) +- DNS rebinding attacks + +**Risk:** In Cloudflare Workers the attack surface is limited — Workers run in a sandboxed V8 isolate with no access to the host network stack. Internal IPs are typically blocked by Cloudflare's infrastructure. However, `http://` URLs (not just `https://`) are accepted, and an attacker could probe internal services if the Cloudflare Worker network allows intra-datacenter connections. + +**Recommendation:** Block private IP ranges, `http://` protocol (require `https://` only), and add a redirect-following limit. + +--- + +### FINDING 4 — Missing Request Body Size Limit +**Severity: MEDIUM** +**File:** `worker/http.ts:64-69` — `parseJsonBody()` + +```ts +export function parseJsonBody(request: Request): Promise { + const contentType = request.headers.get("content-type") || ""; + if (contentType && !contentType.toLowerCase().includes("application/json")) { + throw new HttpError("Content-Type must be application/json", 415); + } + return request.json() as Promise; +} +``` + +No size limit is enforced on the incoming request body. An attacker could send arbitrarily large JSON payloads to exhaust worker memory. + +**Risk:** Cloudflare Workers have a 128 MiB memory limit per invocation. A large enough payload could cause OOM or trigger the worker execution timeout (30s for unbound, 10s for standard). In practice, the request itself would be limited by Cloudflare's 100 MiB request body cap, but there's no explicit limit in code. + +**Recommendation:** Read body as `ArrayBuffer` first and check size before calling `.json()`. + +--- + +### FINDING 5 — Client-Side API Key in localStorage +**Severity: LOW** +**File:** `src/chat.ts:66-68, 130-136, 996-1006` + +```ts +const REMEMBERED_KEY = "cursor-chat.apiKey"; +// ... +if (refs.keyRemember.checked) localStorage.setItem(REMEMBERED_KEY, value); +``` + +The chat UI optionally stores the user's Cursor API key in `localStorage`. This is readable by any JavaScript running on the same origin. + +**Risk:** If the SPA is compromised (e.g., via XSS through the markdown renderer), the attacker can exfiltrate stored API keys. The key is stored in plaintext. + +**Recommendation:** Consider `sessionStorage` instead, or at minimum document the tradeoff. The current implementation is acceptable for a dev tool but should be noted. + +--- + +### FINDING 6 — Markdown Renderer Has Potential XSS Surface +**Severity: LOW** +**File:** `src/markdown.ts:204-216` — `renderInline()` + +```ts +function renderInline(value: string): string { + let text = escapeHtml(value); + text = text.replace(/`([^`]+)`/g, "$1"); + text = text.replace(/\*\*([^*]+)\*\*/g, "$1"); + text = text.replace( + /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, + (_match, label: string, href: string) => + `${label}` + ); + // ... +} +``` + +The markdown renderer: +- ✅ Properly escapes HTML before inline processing (`escapeHtml(value)`) +- ✅ Regex captures only after escaping, so `