fix(cliproxyapi): address PR #914 review — types, SSRF, SQL injection

- Add proper TS interfaces to CliproxyapiSettingsTab (replace any/Record)
- Add HTTP status checks and error handling on all fetches
- Add client-side URL validation before saving proxy URL
- Add error state display for version manager service
- Document localhost SSRF exception in isPrivateHost
- Add ALLOWED_COLUMNS whitelist to updateVersionManagerTool
This commit is contained in:
oyi77 2026-04-02 15:06:09 +07:00
parent 8fc97a7f91
commit 90ed6163f5
9 changed files with 413 additions and 16 deletions

250
.omc/project-memory.json Normal file
View file

@ -0,0 +1,250 @@
{
"version": "1.0.0",
"lastScanned": 1775016362438,
"projectRoot": "/home/openclaw/omniroute-src",
"techStack": {
"languages": [
{
"name": "JavaScript/TypeScript",
"version": ">=18.0.0 <24.0.0",
"confidence": "high",
"markers": ["package.json"]
},
{
"name": "TypeScript",
"version": null,
"confidence": "high",
"markers": ["tsconfig.json"]
}
],
"frameworks": [
{
"name": "express",
"version": "5.2.1",
"category": "backend"
},
{
"name": "next",
"version": "16.0.10",
"category": "fullstack"
},
{
"name": "react",
"version": "19.2.4",
"category": "frontend"
},
{
"name": "react-dom",
"version": "19.2.4",
"category": "frontend"
},
{
"name": "@playwright/test",
"version": "1.58.2",
"category": "testing"
},
{
"name": "vitest",
"version": "4.0.18",
"category": "testing"
}
],
"packageManager": "npm",
"runtime": "Node.js 18.0.024.0.0"
},
"build": {
"buildCommand": "npm run build",
"testCommand": "npm test",
"lintCommand": "npm run lint",
"devCommand": "npm run dev",
"scripts": {
"dev": "node scripts/run-next.mjs dev",
"build": "node scripts/build-next-isolated.mjs",
"build:cli": "node scripts/prepublish.mjs",
"start": "node scripts/run-next.mjs start",
"lint": "eslint .",
"electron:dev": "concurrently \"npm run dev\" \"wait-on http://localhost:20128 && cd electron && npm run dev\"",
"electron:build": "npm run build && cd electron && npm run build",
"electron:build:win": "npm run build && cd electron && npm run build:win",
"electron:build:mac": "npm run build && cd electron && npm run build:mac",
"electron:build:linux": "npm run build && cd electron && npm run build:linux",
"test": "node --import tsx/esm --test tests/unit/*.test.mjs",
"test:unit": "node --import tsx/esm --test tests/unit/*.test.mjs",
"test:plan3": "node --import tsx/esm --test tests/unit/plan3-p0.test.mjs",
"test:fixes": "node --import tsx/esm --test tests/unit/fixes-p1.test.mjs",
"test:security": "node --import tsx/esm --test tests/unit/security-fase01.test.mjs",
"check:cycles": "node scripts/check-cycles.mjs",
"check:route-validation:t06": "node scripts/check-route-validation.mjs",
"check:any-budget:t11": "node scripts/check-t11-any-budget.mjs",
"check:docs-sync": "node scripts/check-docs-sync.mjs",
"typecheck:core": "tsc --pretty false -p tsconfig.typecheck-core.json",
"typecheck:noimplicit:core": "tsc --pretty false -p tsconfig.typecheck-noimplicit-core.json",
"test:integration": "node --import tsx/esm --test tests/integration/*.test.mjs",
"test:e2e": "node scripts/run-playwright-tests.mjs test tests/e2e/*.spec.ts",
"test:protocols:e2e": "node scripts/run-protocol-clients-tests.mjs",
"test:vitest": "vitest run open-sse/mcp-server/__tests__/*.test.ts open-sse/services/autoCombo/__tests__/*.test.ts",
"test:ecosystem": "node scripts/run-ecosystem-tests.mjs",
"test:coverage": "c8 --exclude=tests/** --exclude=**/*.test.* --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov --check-coverage --statements 55 --lines 55 --functions 55 --branches 60 node --import tsx/esm --test tests/unit/*.test.mjs",
"test:coverage:legacy": "c8 --exclude=open-sse --check-coverage --lines 50 --functions 50 --branches 50 node --import tsx/esm --test tests/unit/*.test.mjs",
"coverage:report": "c8 report --exclude=tests/** --exclude=**/*.test.* --reporter=text --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov",
"coverage:report:legacy": "c8 report --exclude=open-sse --reporter=text --reporter=text-summary",
"test:all": "npm run test:unit && npm run test:vitest && npm run test:ecosystem && npm run test:e2e",
"check": "npm run lint && npm run test",
"prepublishOnly": "npm run build:cli",
"postinstall": "node scripts/postinstall.mjs",
"prepare": "husky",
"system-info": "node scripts/system-info.mjs"
}
},
"conventions": {
"namingStyle": "camelCase",
"importStyle": null,
"testPattern": null,
"fileOrganization": null
},
"structure": {
"isMonorepo": true,
"workspaces": ["open-sse"],
"mainDirectories": ["bin", "docs", "public", "scripts", "src", "tests"],
"gitBranches": {
"defaultBranch": "main",
"branchingStrategy": null
}
},
"customNotes": [],
"directoryMap": {
"bin": {
"path": "bin",
"purpose": "Executable scripts",
"fileCount": 3,
"lastAccessed": 1775016362426,
"keyFiles": ["mcp-server.mjs", "omniroute.mjs", "reset-password.mjs"]
},
"docs": {
"path": "docs",
"purpose": "Documentation",
"fileCount": 14,
"lastAccessed": 1775016362426,
"keyFiles": [
"A2A-SERVER.md",
"API_REFERENCE.md",
"ARCHITECTURE.md",
"AUTO-COMBO.md",
"CLI-TOOLS.md"
]
},
"electron": {
"path": "electron",
"purpose": null,
"fileCount": 5,
"lastAccessed": 1775016362431,
"keyFiles": ["README.md", "main.js", "package.json", "preload.js", "types.d.ts"]
},
"images": {
"path": "images",
"purpose": null,
"fileCount": 1,
"lastAccessed": 1775016362434,
"keyFiles": ["omniroute.png"]
},
"logs": {
"path": "logs",
"purpose": null,
"fileCount": 3,
"lastAccessed": 1775016362434,
"keyFiles": ["build_clean_tools.log", "build_debug.log", "build_force_clean.log"]
},
"open-sse": {
"path": "open-sse",
"purpose": null,
"fileCount": 5,
"lastAccessed": 1775016362434,
"keyFiles": ["index.ts", "package.json", "tsconfig.json", "types.d.ts"]
},
"public": {
"path": "public",
"purpose": "Public files",
"fileCount": 3,
"lastAccessed": 1775016362435,
"keyFiles": ["apple-touch-icon.svg", "favicon.svg", "icon-192.svg"]
},
"scripts": {
"path": "scripts",
"purpose": "Build/utility scripts",
"fileCount": 23,
"lastAccessed": 1775016362435,
"keyFiles": [
"bootstrap-env.mjs",
"build-next-isolated.mjs",
"check-cycles.mjs",
"check-docs-sync.mjs",
"check-route-validation.mjs"
]
},
"src": {
"path": "src",
"purpose": "Source code",
"fileCount": 4,
"lastAccessed": 1775016362435,
"keyFiles": ["instrumentation-node.ts", "instrumentation.ts", "proxy.ts", "server-init.ts"]
},
"tests": {
"path": "tests",
"purpose": "Test files",
"fileCount": 0,
"lastAccessed": 1775016362435,
"keyFiles": []
},
"electron/assets": {
"path": "electron/assets",
"purpose": "Static assets",
"fileCount": 4,
"lastAccessed": 1775016362436,
"keyFiles": ["icon.icns", "icon.ico", "icon.png"]
},
"open-sse/config": {
"path": "open-sse/config",
"purpose": "Configuration files",
"fileCount": 17,
"lastAccessed": 1775016362436,
"keyFiles": ["audioRegistry.ts", "cliFingerprints.ts", "codexInstructions.ts"]
},
"open-sse/services": {
"path": "open-sse/services",
"purpose": "Business logic services",
"fileCount": 35,
"lastAccessed": 1775016362437,
"keyFiles": ["accountFallback.ts", "accountSelector.ts", "apiKeyRotator.ts"]
},
"src/app": {
"path": "src/app",
"purpose": "Application code",
"fileCount": 7,
"lastAccessed": 1775016362438,
"keyFiles": ["error.tsx", "global-error.tsx", "globals.css"]
},
"src/lib": {
"path": "src/lib",
"purpose": "Library code",
"fileCount": 30,
"lastAccessed": 1775016362438,
"keyFiles": ["apiBridgeServer.ts", "apiKeyExposure.ts", "cacheControlSettings.ts"]
},
"src/middleware": {
"path": "src/middleware",
"purpose": "Middleware",
"fileCount": 1,
"lastAccessed": 1775016362438,
"keyFiles": ["promptInjectionGuard.ts"]
},
"src/models": {
"path": "src/models",
"purpose": "Data models",
"fileCount": 1,
"lastAccessed": 1775016362438,
"keyFiles": ["index.ts"]
}
},
"hotPaths": [],
"userDirectives": []
}

View file

@ -0,0 +1,8 @@
{
"session_id": "53c002c3-36a6-47c3-a52d-a8f756c264eb",
"ended_at": "2026-04-01T04:06:04.924Z",
"reason": "prompt_input_exit",
"agents_spawned": 0,
"agents_completed": 0,
"modes_used": []
}

View file

@ -1,34 +1,82 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import { Card, Button, Input, Toggle } from "@/shared/components";
interface Settings {
cliproxyapi_fallback_enabled?: boolean;
cliproxyapi_url?: string;
cliproxyapi_fallback_codes?: string;
[key: string]: unknown;
}
interface VersionManagerEntry {
tool: string;
status: string;
installedVersion: string | null;
healthStatus: string;
port: number;
}
function isValidUrl(value: string): boolean {
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
}
export default function CliproxyapiSettingsTab() {
const [settings, setSettings] = useState<Record<string, any>>({});
const [settings, setSettings] = useState<Settings>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ type: string; text: string } | null>(null);
const [toolState, setToolState] = useState<any>(null);
const [toolState, setToolState] = useState<VersionManagerEntry | null>(null);
const [toolStateError, setToolStateError] = useState<string | null>(null);
useEffect(() => {
fetch("/api/settings")
.then((r) => r.json())
.then((r) => {
if (!r.ok) throw new Error(`Settings API returned ${r.status}`);
return r.json();
})
.then((data) => {
setSettings(data);
setLoading(false);
})
.catch(() => setLoading(false));
.catch((err) => {
console.error("Failed to load settings:", err);
setLoading(false);
});
fetch("/api/version-manager/status")
.then((r) => r.json())
.then((data) => {
const entry = Array.isArray(data) ? data.find((t: any) => t.tool === "cliproxyapi") : null;
setToolState(entry);
.then((r) => {
if (!r.ok) throw new Error(`Version manager API returned ${r.status}`);
return r.json();
})
.catch(() => {});
.then((data) => {
const entry = Array.isArray(data)
? data.find((t: VersionManagerEntry) => t.tool === "cliproxyapi")
: null;
setToolState(entry ?? null);
setToolStateError(null);
})
.catch((err) => {
console.error("Failed to load version manager status:", err);
setToolStateError("Unable to reach version manager service");
setToolState(null);
});
}, []);
const updateSetting = async (key: string, value: any) => {
const updateSetting = useCallback(async (key: string, value: boolean | string) => {
if (key === "cliproxyapi_url" && typeof value === "string" && value.trim() !== "") {
if (!isValidUrl(value)) {
setMessage({ type: "error", text: "Invalid URL format. Use http:// or https://" });
return;
}
}
setSaving(true);
setMessage(null);
try {
@ -37,16 +85,18 @@ export default function CliproxyapiSettingsTab() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ [key]: value }),
});
if (res.ok) {
setSettings((prev) => ({ ...prev, [key]: value }));
setMessage({ type: "success", text: "Setting saved" });
if (!res.ok) {
throw new Error(`Server returned ${res.status}`);
}
await res.json();
setSettings((prev) => ({ ...prev, [key]: value }));
setMessage({ type: "success", text: "Setting saved" });
} catch {
setMessage({ type: "error", text: "Failed to save setting" });
} finally {
setSaving(false);
}
};
}, []);
const cpaEnabled = settings.cliproxyapi_fallback_enabled === true;
const cpaUrl = settings.cliproxyapi_url || "http://127.0.0.1:8317";
@ -142,6 +192,8 @@ export default function CliproxyapiSettingsTab() {
</span>
Loading...
</div>
) : toolStateError ? (
<p className="text-sm text-text-muted">{toolStateError}</p>
) : toolState ? (
<div className="grid grid-cols-2 gap-3">
<div className="p-3 rounded-lg bg-bg-secondary">

View file

@ -0,0 +1,22 @@
-- 014_create_memories.sql
-- Memories table for persistent context storage.
-- Stores structured conversation memories with support for different memory types.
CREATE TABLE IF NOT EXISTS memories (
id TEXT PRIMARY KEY,
api_key_id TEXT NOT NULL,
session_id TEXT,
type TEXT NOT NULL CHECK(type IN ('factual', 'episodic', 'procedural', 'semantic')),
key TEXT,
content TEXT NOT NULL,
metadata TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT
);
-- Indexes for performance optimization
CREATE INDEX IF NOT EXISTS idx_memories_api_key ON memories(api_key_id);
CREATE INDEX IF NOT EXISTS idx_memories_session ON memories(session_id);
CREATE INDEX IF NOT EXISTS idx_memories_type ON memories(type);
CREATE INDEX IF NOT EXISTS idx_memories_expires ON memories(expires_at);

View file

@ -0,0 +1,4 @@
-- 014_create_memories_down.sql
-- DOWN Migration: Remove memories table (Rollback)
DROP TABLE IF EXISTS memories;

View file

@ -0,0 +1,37 @@
-- 015_create_skills.sql
-- Skills table for tool/function capability injection.
-- Stores skill definitions with schemas and execution tracking.
CREATE TABLE IF NOT EXISTS skills (
id TEXT PRIMARY KEY,
api_key_id TEXT NOT NULL,
name TEXT NOT NULL,
version TEXT NOT NULL DEFAULT '1.0.0',
description TEXT,
schema TEXT NOT NULL,
handler TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS skill_executions (
id TEXT PRIMARY KEY,
skill_id TEXT NOT NULL,
api_key_id TEXT NOT NULL,
session_id TEXT,
input TEXT NOT NULL,
output TEXT,
status TEXT NOT NULL CHECK(status IN ('pending', 'running', 'success', 'error', 'timeout')),
error_message TEXT,
duration_ms INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (skill_id) REFERENCES skills(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_skills_api_key ON skills(api_key_id);
CREATE INDEX IF NOT EXISTS idx_skills_name ON skills(name);
CREATE INDEX IF NOT EXISTS idx_skill_executions_skill ON skill_executions(skill_id);
CREATE INDEX IF NOT EXISTS idx_skill_executions_api_key ON skill_executions(api_key_id);
CREATE INDEX IF NOT EXISTS idx_skill_executions_status ON skill_executions(status);
CREATE INDEX IF NOT EXISTS idx_skill_executions_created ON skill_executions(created_at);

View file

@ -0,0 +1,5 @@
-- 015_create_skills_down.sql
-- Rollback skills and skill_executions tables
DROP TABLE IF EXISTS skill_executions;
DROP TABLE IF EXISTS skills;

View file

@ -32,8 +32,9 @@ function toRecord(value: unknown): Record<string, unknown> {
const BLOCKED_HOSTNAMES = ["metadata.google.internal", "169.254.169.254", "metadata.aws.internal"];
function isPrivateHost(hostname: string): boolean {
if (BLOCKED_HOSTNAMES.includes(hostname)) return true;
// CLIProxyAPI runs on localhost:8317 — allow loopback explicitly
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") return false;
if (BLOCKED_HOSTNAMES.includes(hostname)) return true;
if (
/^10\./.test(hostname) ||
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname) ||

View file

@ -226,10 +226,28 @@ export async function updateVersionManagerTool(
const existing = await getVersionManagerTool(tool);
if (!existing) return null;
const ALLOWED_COLUMNS = new Set([
"currentVersion",
"installedVersion",
"pinnedVersion",
"binaryPath",
"status",
"pid",
"port",
"apiKey",
"managementKey",
"autoUpdate",
"autoStart",
"healthStatus",
"configOverrides",
"errorMessage",
]);
const sets: string[] = ["updated_at = datetime('now')"];
const params: Record<string, unknown> = { tool };
for (const [key, value] of Object.entries(updates)) {
if (!ALLOWED_COLUMNS.has(key)) continue;
const dbKey = key.replace(/([A-Z])/g, "_$1").toLowerCase();
if (key === "configOverrides") {