fix: resolve issues #273, #276, #277 — image routing, models route, missing-key error (#282)

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.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-03-10 09:48:50 -03:00
committed by GitHub
parent f900a81ec9
commit ce560ebe9d
12 changed files with 136 additions and 25 deletions

View File

@@ -10,6 +10,9 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
lint:
name: Lint

1
.gitignore vendored
View File

@@ -102,7 +102,6 @@ cloud/
security-analysis/
# Deploy workflow (contains sensitive VPS credentials)
.agent/workflows/deploy.md
clipr/
app.log
*.tgz

View File

@@ -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"],
},

View File

@@ -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<string, unknown>;
const err = e?.error as Record<string, unknown> | undefined;
const details = (err?.details as Record<string, unknown>[] | undefined)?.[0];
const debug = details?.debug as Record<string, unknown> | undefined;
const debugDetails = debug?.details as Record<string, unknown> | 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");

30
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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
*/

View File

@@ -247,6 +247,14 @@ const PROVIDER_MODELS_CONFIG: Record<string, ProviderModelsConfigEntry> = {
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

View File

@@ -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_<timestamp>_<reason>.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}`);
}

View File

@@ -35,8 +35,8 @@ const pendingRequests: {
byModel: Record<string, number>;
byAccount: Record<string, Record<string, number>>;
} = {
byModel: {},
byAccount: {},
byModel: Object.create(null) as Record<string, number>,
byAccount: Object.create(null) as Record<string, Record<string, number>>,
};
/**
@@ -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<string, number>;
}
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)

View File

@@ -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);

View File

@@ -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 {