From ce560ebe9d49b4c601f54ad9ebcfaf7e9009a778 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 10 Mar 2026 09:48:50 -0300 Subject: [PATCH] =?UTF-8?q?fix:=20resolve=20issues=20#273,=20#276,=20#277?= =?UTF-8?q?=20=E2=80=94=20image=20routing,=20models=20route,=20missing-key?= =?UTF-8?q?=20error=20(#282)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squash merge PR #282: bug fixes for #273 (Gemini image routing), #276 (Ollama Cloud models), #277 (missing apiKey error), lint fix, and all security code-scanning patches. --- .github/workflows/ci.yml | 3 ++ .gitignore | 1 - open-sse/config/imageRegistry.ts | 5 +++- open-sse/executors/cursor.ts | 15 ++++++---- package-lock.json | 30 +++++++++++++++++-- package.json | 1 + src/app/api/cli-tools/codex-profiles/route.ts | 13 ++++++++ src/app/api/providers/[id]/models/route.ts | 16 +++++++++- src/lib/db/backup.ts | 19 ++++++++++-- src/lib/usage/usageHistory.ts | 16 ++++++---- src/mitm/server.ts | 20 +++++++++++-- src/shared/services/backupService.ts | 22 +++++++++++--- 12 files changed, 136 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7d5910edd..864bc8f5f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +permissions: + contents: read + jobs: lint: name: Lint diff --git a/.gitignore b/.gitignore index e0efed148b..83dd110f98 100644 --- a/.gitignore +++ b/.gitignore @@ -102,7 +102,6 @@ cloud/ security-analysis/ # Deploy workflow (contains sensitive VPS credentials) -.agent/workflows/deploy.md clipr/ app.log *.tgz diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index ed5a51c9ea..9769431f17 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -63,7 +63,10 @@ export const IMAGE_PROVIDERS = { authType: "oauth", authHeader: "bearer", format: "gemini-image", // Special format: uses Gemini generateContent API - models: [{ id: "gemini-2.5-flash-preview-image-generation", name: "Nano Banana" }], + models: [ + { id: "gemini-2.5-flash-preview-image-generation", name: "Gemini 2.5 Flash Image" }, + { id: "gemini-3.1-flash-image-preview", name: "Gemini 3.1 Flash Image Preview" }, + ], supportedSizes: ["1024x1024"], }, diff --git a/open-sse/executors/cursor.ts b/open-sse/executors/cursor.ts index b3b135ee80..8577a54f16 100644 --- a/open-sse/executors/cursor.ts +++ b/open-sse/executors/cursor.ts @@ -148,12 +148,17 @@ function parseCursorJsonErrorFrame(text: string) { } } -function isToolBoundaryAbort(jsonError: any, toolCallCount: number) { +function isToolBoundaryAbort(jsonError: unknown, toolCallCount: number) { if (!jsonError || toolCallCount <= 0) return false; - const code = jsonError?.error?.code || ""; - const debugError = jsonError?.error?.details?.[0]?.debug?.error || ""; - const title = jsonError?.error?.details?.[0]?.debug?.details?.title || ""; - const detail = jsonError?.error?.details?.[0]?.debug?.details?.detail || ""; + const e = jsonError as Record; + const err = e?.error as Record | undefined; + const details = (err?.details as Record[] | undefined)?.[0]; + const debug = details?.debug as Record | undefined; + const debugDetails = debug?.details as Record | undefined; + const code = (err?.code as string) || ""; + const debugError = (debug?.error as string) || ""; + const title = (debugDetails?.title as string) || ""; + const detail = (debugDetails?.detail as string) || ""; const message = `${title} ${detail}`.toLowerCase(); const isAbortedCode = code === "aborted" || debugError === "ERROR_USER_ABORTED_REQUEST"; return isAbortedCode && message.includes("tool call ended before result was received"); diff --git a/package-lock.json b/package-lock.json index 8111259d39..5f46984526 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "bcryptjs": "^3.0.3", "better-sqlite3": "^12.6.2", "bottleneck": "^2.19.5", + "dompurify": "^3.3.2", "express": "^5.2.1", "fetch-socks": "^1.3.2", "http-proxy-middleware": "^3.0.5", @@ -5674,10 +5675,13 @@ } }, "node_modules/dompurify": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", - "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz", + "integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==", "license": "(MPL-2.0 OR Apache-2.0)", + "engines": { + "node": ">=20" + }, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -8774,6 +8778,15 @@ "marked": "14.0.0" } }, + "node_modules/monaco-editor/node_modules/dompurify": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", + "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -8965,6 +8978,17 @@ } } }, + "node_modules/next-intl/node_modules/@swc/helpers": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz", + "integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", diff --git a/package.json b/package.json index 7fb78f76b2..ea5bcc08b7 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "bcryptjs": "^3.0.3", "better-sqlite3": "^12.6.2", "bottleneck": "^2.19.5", + "dompurify": "^3.3.2", "express": "^5.2.1", "fetch-socks": "^1.3.2", "http-proxy-middleware": "^3.0.5", diff --git a/src/app/api/cli-tools/codex-profiles/route.ts b/src/app/api/cli-tools/codex-profiles/route.ts index 18192217f3..feb790a444 100644 --- a/src/app/api/cli-tools/codex-profiles/route.ts +++ b/src/app/api/cli-tools/codex-profiles/route.ts @@ -10,6 +10,19 @@ import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; const PROFILES_DIR = path.join(resolveDataDir(), "codex-profiles"); +/** + * Resolve a path inside PROFILES_DIR and verify it stays within bounds. + * Throws on path traversal attempts. + */ +function safeProfilePath(...segments: string[]): string { + const resolved = path.resolve(PROFILES_DIR, ...segments); + const base = path.resolve(PROFILES_DIR); + if (resolved !== base && !resolved.startsWith(base + path.sep)) { + throw new Error("Invalid path: directory traversal detected"); + } + return resolved; +} + /** * Ensure profiles directory exists */ diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index c2ca2b6994..64b35b1288 100644 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -247,6 +247,14 @@ const PROVIDER_MODELS_CONFIG: Record = { authPrefix: "Bearer ", parseResponse: (data) => data.data || data.models || [], }, + "ollama-cloud": { + url: "https://api.ollama.com/v1/models", + method: "GET", + headers: { "Content-Type": "application/json" }, + authHeader: "Authorization", + authPrefix: "Bearer ", + parseResponse: (data) => data.models || data.data || [], + }, }; /** @@ -389,7 +397,13 @@ export async function GET(request, { params }) { // Get auth token const token = accessToken || apiKey; if (!token) { - return NextResponse.json({ error: "No valid token found" }, { status: 401 }); + return NextResponse.json( + { + error: + "No API key configured for this provider. Please add an API key in the provider settings.", + }, + { status: 400 } + ); } // Build request URL diff --git a/src/lib/db/backup.ts b/src/lib/db/backup.ts index e061acf611..e699acdf0e 100644 --- a/src/lib/db/backup.ts +++ b/src/lib/db/backup.ts @@ -159,11 +159,26 @@ export async function listDbBackups() { export async function restoreDbBackup(backupId: string) { const backupDir = DB_BACKUPS_DIR || path.join(DATA_DIR, "db_backups"); - const backupPath = path.join(backupDir, backupId); - if (!backupId.startsWith("db_") || !backupId.endsWith(".sqlite")) { + // Validate format: must be db__.sqlite, no path separators + if ( + !backupId.startsWith("db_") || + !backupId.endsWith(".sqlite") || + backupId.includes(path.sep) || + backupId.includes("/") + ) { throw new Error("Invalid backup ID"); } + + const backupPath = path.resolve(backupDir, backupId); + // Prevent path traversal: resolved path must stay within backupDir + if ( + !backupPath.startsWith(path.resolve(backupDir) + path.sep) && + backupPath !== path.resolve(backupDir) + ) { + throw new Error("Invalid backup ID: path traversal detected"); + } + if (!fs.existsSync(backupPath)) { throw new Error(`Backup not found: ${backupId}`); } diff --git a/src/lib/usage/usageHistory.ts b/src/lib/usage/usageHistory.ts index 54a6991dd6..8da8c744ef 100644 --- a/src/lib/usage/usageHistory.ts +++ b/src/lib/usage/usageHistory.ts @@ -35,8 +35,8 @@ const pendingRequests: { byModel: Record; byAccount: Record>; } = { - byModel: {}, - byAccount: {}, + byModel: Object.create(null) as Record, + byAccount: Object.create(null) as Record>, }; /** @@ -50,16 +50,22 @@ export function trackPendingRequest( ) { const modelKey = provider ? `${model} (${provider})` : model; - if (!pendingRequests.byModel[modelKey]) pendingRequests.byModel[modelKey] = 0; + // Use hasOwnProperty guard to prevent prototype pollution via crafted keys + if (!Object.prototype.hasOwnProperty.call(pendingRequests.byModel, modelKey)) { + pendingRequests.byModel[modelKey] = 0; + } pendingRequests.byModel[modelKey] = Math.max( 0, pendingRequests.byModel[modelKey] + (started ? 1 : -1) ); if (connectionId) { - if (!pendingRequests.byAccount[connectionId]) pendingRequests.byAccount[connectionId] = {}; - if (!pendingRequests.byAccount[connectionId][modelKey]) + if (!Object.prototype.hasOwnProperty.call(pendingRequests.byAccount, connectionId)) { + pendingRequests.byAccount[connectionId] = Object.create(null) as Record; + } + if (!Object.prototype.hasOwnProperty.call(pendingRequests.byAccount[connectionId], modelKey)) { pendingRequests.byAccount[connectionId][modelKey] = 0; + } pendingRequests.byAccount[connectionId][modelKey] = Math.max( 0, pendingRequests.byAccount[connectionId][modelKey] + (started ? 1 : -1) diff --git a/src/mitm/server.ts b/src/mitm/server.ts index 7c5b95bb1e..6e69aed240 100644 --- a/src/mitm/server.ts +++ b/src/mitm/server.ts @@ -45,12 +45,22 @@ const CHAT_URL_PATTERNS = [":generateContent", ":streamGenerateContent"]; const LOG_DIR = path.join(__dirname, "../../logs/mitm"); if (ENABLE_FILE_LOG && !fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true }); +// Safe log filename: only alphanumeric + hyphens, anchored inside LOG_DIR +function safeLogPath(name) { + const safe = name.replace(/[^a-zA-Z0-9_\-]/g, "_").substring(0, 80); + const resolved = path.resolve(LOG_DIR, safe); + if (!resolved.startsWith(path.resolve(LOG_DIR) + path.sep)) { + throw new Error("Path traversal attempt detected in log filename"); + } + return resolved; +} + function saveRequestLog(url, bodyBuffer) { if (!ENABLE_FILE_LOG) return; try { const ts = new Date().toISOString().replace(/[:.]/g, "-"); const urlSlug = url.replace(/[^a-zA-Z0-9]/g, "_").substring(0, 60); - const filePath = path.join(LOG_DIR, `${ts}_${urlSlug}.json`); + const filePath = safeLogPath(`${ts}_${urlSlug}.json`); const body = JSON.parse(bodyBuffer.toString()); fs.writeFileSync(filePath, JSON.stringify(body, null, 2)); console.log(`💾 Saved request: ${filePath}`); @@ -64,7 +74,7 @@ function saveResponseLog(url, data) { try { const ts = new Date().toISOString().replace(/[:.]/g, "-"); const urlSlug = url.replace(/[^a-zA-Z0-9]/g, "_").substring(0, 60); - const filePath = path.join(LOG_DIR, `${ts}_${urlSlug}_response.txt`); + const filePath = safeLogPath(`${ts}_${urlSlug}_response.txt`); fs.writeFileSync(filePath, data); console.log(`💾 Saved response: ${filePath}`); } catch { @@ -156,6 +166,10 @@ function getMappedModel(model) { async function passthrough(req, res, bodyBuffer) { const targetIP = await resolveTargetIP(); + // TLS validation is enabled by default. Set MITM_DISABLE_TLS_VERIFY=1 only + // in controlled local environments where the target uses a self-signed cert. + const rejectUnauthorized = process.env.MITM_DISABLE_TLS_VERIFY !== "1"; + const forwardReq = https.request( { hostname: targetIP, @@ -164,7 +178,7 @@ async function passthrough(req, res, bodyBuffer) { method: req.method, headers: { ...req.headers, host: TARGET_HOST }, servername: TARGET_HOST, - rejectUnauthorized: false, + rejectUnauthorized, }, (forwardRes) => { res.writeHead(forwardRes.statusCode, forwardRes.headers); diff --git a/src/shared/services/backupService.ts b/src/shared/services/backupService.ts index f948e4492e..0809b5908a 100644 --- a/src/shared/services/backupService.ts +++ b/src/shared/services/backupService.ts @@ -5,11 +5,24 @@ import { resolveDataDir } from "@/lib/dataPaths"; const BACKUP_DIR = path.join(resolveDataDir(), "backups"); const MAX_BACKUPS_PER_TOOL = 5; +/** + * Resolve a path within BACKUP_DIR and verify it stays within bounds. + * Throws if the resolved path escapes BACKUP_DIR (path traversal guard). + */ +function safePath(...segments: string[]): string { + const resolved = path.resolve(BACKUP_DIR, ...segments); + const base = path.resolve(BACKUP_DIR); + if (resolved !== base && !resolved.startsWith(base + path.sep)) { + throw new Error("Invalid path: directory traversal detected"); + } + return resolved; +} + /** * Get backup directory for a specific tool */ function getToolBackupDir(toolId: string) { - return path.join(BACKUP_DIR, toolId); + return safePath(toolId); } /** @@ -136,7 +149,8 @@ export async function listBackups(toolId: string) { */ export async function restoreBackup(toolId: string, backupId: string) { const dir = getToolBackupDir(toolId); - const backupPath = path.join(dir, backupId); + // Anchor backupId within the tool dir — prevent path traversal via backupId + const backupPath = safePath(toolId, backupId); const metaPath = backupPath + ".meta.json"; // Read metadata to find original path @@ -174,8 +188,8 @@ export async function restoreBackup(toolId: string, backupId: string) { * Delete a specific backup by its id. */ export async function deleteBackup(toolId: string, backupId: string) { - const dir = getToolBackupDir(toolId); - const backupPath = path.join(dir, backupId); + // Anchor backupId within the tool dir — prevent path traversal via backupId + const backupPath = safePath(toolId, backupId); const metaPath = backupPath + ".meta.json"; try {