mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 07:12:12 +03:00
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:
committed by
GitHub
parent
f900a81ec9
commit
ce560ebe9d
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -10,6 +10,9 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -102,7 +102,6 @@ cloud/
|
||||
security-analysis/
|
||||
|
||||
# Deploy workflow (contains sensitive VPS credentials)
|
||||
.agent/workflows/deploy.md
|
||||
clipr/
|
||||
app.log
|
||||
*.tgz
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
|
||||
|
||||
@@ -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
30
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user