Compare commits

..

4 Commits

Author SHA1 Message Date
Haileyesus
77fb193598 fix: resolve type error in MobileNav and PluginTabContent components 2026-03-09 12:59:52 +03:00
Haileyesus
ff45a1cfd7 refactor: move plugin content to /view folder 2026-03-09 12:51:48 +03:00
simosmik
951db47a91 fix(git-panel): reset changes view on project switch 2026-03-09 08:18:58 +00:00
simosmik
ca16342a20 fix(plugins): harden input validation and scan reliability
- Validate plugin names against [a-zA-Z0-9_-] allowlist in
  manifest and asset routes to prevent path traversal via URL
- Strip embedded credentials (user:pass@) from git remote URLs
  before exposing them to the client
- Skip .tmp-* directories during scan to avoid partial installs
  from in-progress updates appearing as broken plugins
- Deduplicate plugins sharing the same manifest name to prevent
  ambiguous state
- Guard RPC proxy error handler against writing to an already-sent
  response, preventing uncaught exceptions on aborted requests
2026-03-09 07:59:46 +00:00
10 changed files with 111 additions and 32 deletions

View File

@@ -39,6 +39,9 @@ router.get('/', (req, res) => {
// GET /:name/manifest — Get single plugin manifest // GET /:name/manifest — Get single plugin manifest
router.get('/:name/manifest', (req, res) => { router.get('/:name/manifest', (req, res) => {
try { try {
if (!/^[a-zA-Z0-9_-]+$/.test(req.params.name)) {
return res.status(400).json({ error: 'Invalid plugin name' });
}
const plugins = scanPlugins(); const plugins = scanPlugins();
const plugin = plugins.find(p => p.name === req.params.name); const plugin = plugins.find(p => p.name === req.params.name);
if (!plugin) { if (!plugin) {
@@ -53,6 +56,9 @@ router.get('/:name/manifest', (req, res) => {
// GET /:name/assets/* — Serve plugin static files // GET /:name/assets/* — Serve plugin static files
router.get('/:name/assets/*', (req, res) => { router.get('/:name/assets/*', (req, res) => {
const pluginName = req.params.name; const pluginName = req.params.name;
if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {
return res.status(400).json({ error: 'Invalid plugin name' });
}
const assetPath = req.params[0]; const assetPath = req.params[0];
if (!assetPath) { if (!assetPath) {
@@ -252,7 +258,11 @@ router.all('/:name/rpc/*', async (req, res) => {
}); });
proxyReq.on('error', (err) => { proxyReq.on('error', (err) => {
res.status(502).json({ error: 'Plugin server error', details: err.message }); if (!res.headersSent) {
res.status(502).json({ error: 'Plugin server error', details: err.message });
} else {
res.end();
}
}); });
// Forward body (already parsed by express JSON middleware, so re-stringify). // Forward body (already parsed by express JSON middleware, so re-stringify).

View File

@@ -7,6 +7,19 @@ const PLUGINS_DIR = path.join(os.homedir(), '.claude-code-ui', 'plugins');
const PLUGINS_CONFIG_PATH = path.join(os.homedir(), '.claude-code-ui', 'plugins.json'); const PLUGINS_CONFIG_PATH = path.join(os.homedir(), '.claude-code-ui', 'plugins.json');
const REQUIRED_MANIFEST_FIELDS = ['name', 'displayName', 'entry']; const REQUIRED_MANIFEST_FIELDS = ['name', 'displayName', 'entry'];
/** Strip embedded credentials from a repo URL before exposing it to the client. */
function sanitizeRepoUrl(raw) {
try {
const u = new URL(raw);
u.username = '';
u.password = '';
return u.toString().replace(/\/$/, '');
} catch {
// Not a parseable URL (e.g. SSH shorthand) — strip user:pass@ segment
return raw.replace(/\/\/[^@/]+@/, '//');
}
}
const ALLOWED_TYPES = ['react', 'module']; const ALLOWED_TYPES = ['react', 'module'];
const ALLOWED_SLOTS = ['tab']; const ALLOWED_SLOTS = ['tab'];
@@ -92,8 +105,12 @@ export function scanPlugins() {
return plugins; return plugins;
} }
const seenNames = new Set();
for (const entry of entries) { for (const entry of entries) {
if (!entry.isDirectory()) continue; if (!entry.isDirectory()) continue;
// Skip transient temp directories from in-progress installs
if (entry.name.startsWith('.tmp-')) continue;
const manifestPath = path.join(pluginsDir, entry.name, 'manifest.json'); const manifestPath = path.join(pluginsDir, entry.name, 'manifest.json');
if (!fs.existsSync(manifestPath)) continue; if (!fs.existsSync(manifestPath)) continue;
@@ -106,6 +123,13 @@ export function scanPlugins() {
continue; continue;
} }
// Skip duplicate manifest names
if (seenNames.has(manifest.name)) {
console.warn(`[Plugins] Skipping ${entry.name}: duplicate plugin name "${manifest.name}"`);
continue;
}
seenNames.add(manifest.name);
// Try to read git remote URL // Try to read git remote URL
let repoUrl = null; let repoUrl = null;
try { try {
@@ -119,6 +143,8 @@ export function scanPlugins() {
if (repoUrl.startsWith('git@')) { if (repoUrl.startsWith('git@')) {
repoUrl = repoUrl.replace(/^git@([^:]+):/, 'https://$1/'); repoUrl = repoUrl.replace(/^git@([^:]+):/, 'https://$1/');
} }
// Strip embedded credentials (e.g. https://user:pass@host/...)
repoUrl = sanitizeRepoUrl(repoUrl);
} }
} }
} catch { /* ignore */ } } catch { /* ignore */ }

View File

@@ -1,13 +1,35 @@
import { useState, useRef, useEffect, Dispatch, SetStateAction } from 'react'; import { useState, useRef, useEffect, type Dispatch, type SetStateAction } from 'react';
import { MessageSquare, Folder, Terminal, GitBranch, ClipboardCheck, Ellipsis, Puzzle, Box, Database, Globe, Wrench, Zap, BarChart3 } from 'lucide-react'; import {
MessageSquare,
Folder,
Terminal,
GitBranch,
ClipboardCheck,
Ellipsis,
Puzzle,
Box,
Database,
Globe,
Wrench,
Zap,
BarChart3,
type LucideIcon,
} from 'lucide-react';
import { useTasksSettings } from '../../contexts/TasksSettingsContext'; import { useTasksSettings } from '../../contexts/TasksSettingsContext';
import { usePlugins } from '../../contexts/PluginsContext'; import { usePlugins } from '../../contexts/PluginsContext';
import { AppTab } from '../../types/app'; import { AppTab } from '../../types/app';
const PLUGIN_ICON_MAP = { const PLUGIN_ICON_MAP: Record<string, LucideIcon> = {
Puzzle, Box, Database, Globe, Terminal, Wrench, Zap, BarChart3, Folder, MessageSquare, GitBranch, Puzzle, Box, Database, Globe, Terminal, Wrench, Zap, BarChart3, Folder, MessageSquare, GitBranch,
}; };
type CoreTabId = Exclude<AppTab, `plugin:${string}` | 'preview'>;
type CoreNavItem = {
id: CoreTabId;
icon: LucideIcon;
label: string;
};
type MobileNavProps = { type MobileNavProps = {
activeTab: AppTab; activeTab: AppTab;
setActiveTab: Dispatch<SetStateAction<AppTab>>; setActiveTab: Dispatch<SetStateAction<AppTab>>;
@@ -19,7 +41,7 @@ export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: M
const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled); const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled);
const { plugins } = usePlugins(); const { plugins } = usePlugins();
const [moreOpen, setMoreOpen] = useState(false); const [moreOpen, setMoreOpen] = useState(false);
const moreRef = useRef(null); const moreRef = useRef<HTMLDivElement | null>(null);
const enabledPlugins = plugins.filter((p) => p.enabled); const enabledPlugins = plugins.filter((p) => p.enabled);
const hasPlugins = enabledPlugins.length > 0; const hasPlugins = enabledPlugins.length > 0;
@@ -28,8 +50,9 @@ export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: M
// Close the menu on outside tap // Close the menu on outside tap
useEffect(() => { useEffect(() => {
if (!moreOpen) return; if (!moreOpen) return;
const handleTap = (e) => { const handleTap = (e: PointerEvent) => {
if (moreRef.current && !moreRef.current.contains(e.target)) { const target = e.target;
if (moreRef.current && target instanceof Node && !moreRef.current.contains(target)) {
setMoreOpen(false); setMoreOpen(false);
} }
}; };
@@ -38,18 +61,21 @@ export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: M
}, [moreOpen]); }, [moreOpen]);
// Close menu when a plugin tab is selected // Close menu when a plugin tab is selected
const selectPlugin = (name) => { const selectPlugin = (name: string) => {
setActiveTab(`plugin:${name}`); const pluginTab = `plugin:${name}` as AppTab;
setActiveTab(pluginTab);
setMoreOpen(false); setMoreOpen(false);
}; };
const coreItems = [ const baseCoreItems: CoreNavItem[] = [
{ id: 'chat', icon: MessageSquare, label: 'Chat' }, { id: 'chat', icon: MessageSquare, label: 'Chat' },
{ id: 'shell', icon: Terminal, label: 'Shell' }, { id: 'shell', icon: Terminal, label: 'Shell' },
{ id: 'files', icon: Folder, label: 'Files' }, { id: 'files', icon: Folder, label: 'Files' },
{ id: 'git', icon: GitBranch, label: 'Git' }, { id: 'git', icon: GitBranch, label: 'Git' },
...(shouldShowTasksTab ? [{ id: 'tasks', icon: ClipboardCheck, label: 'Tasks' }] : []),
]; ];
const coreItems: CoreNavItem[] = shouldShowTasksTab
? [...baseCoreItems, { id: 'tasks', icon: ClipboardCheck, label: 'Tasks' }]
: baseCoreItems;
return ( return (
<div <div

View File

@@ -107,6 +107,7 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen
{activeView === 'changes' && ( {activeView === 'changes' && (
<ChangesView <ChangesView
key={selectedProject.fullPath}
isMobile={isMobile} isMobile={isMobile}
projectPath={selectedProject.fullPath} projectPath={selectedProject.fullPath}
gitStatus={gitStatus} gitStatus={gitStatus}

View File

@@ -3,7 +3,7 @@ import ChatInterface from '../../chat/view/ChatInterface';
import FileTree from '../../file-tree/view/FileTree'; import FileTree from '../../file-tree/view/FileTree';
import StandaloneShell from '../../standalone-shell/view/StandaloneShell'; import StandaloneShell from '../../standalone-shell/view/StandaloneShell';
import GitPanel from '../../git-panel/view/GitPanel'; import GitPanel from '../../git-panel/view/GitPanel';
import PluginTabContent from '../../plugins/PluginTabContent'; import PluginTabContent from '../../plugins/view/PluginTabContent';
import type { MainContentProps } from '../types/types'; import type { MainContentProps } from '../types/types';
import { useTaskMaster } from '../../../contexts/TaskMasterContext'; import { useTaskMaster } from '../../../contexts/TaskMasterContext';
import { useTasksSettings } from '../../../contexts/TasksSettingsContext'; import { useTasksSettings } from '../../../contexts/TasksSettingsContext';

View File

@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { Tooltip } from '../../../../shared/view/ui'; import { Tooltip } from '../../../../shared/view/ui';
import type { AppTab } from '../../../../types/app'; import type { AppTab } from '../../../../types/app';
import { usePlugins } from '../../../../contexts/PluginsContext'; import { usePlugins } from '../../../../contexts/PluginsContext';
import PluginIcon from '../../../plugins/PluginIcon'; import PluginIcon from '../../../plugins/view/PluginIcon';
type MainContentTabSwitcherProps = { type MainContentTabSwitcherProps = {
activeTab: AppTab; activeTab: AppTab;

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { authenticatedFetch } from '../../utils/api'; import { authenticatedFetch } from '../../../utils/api';
type Props = { type Props = {
pluginName: string; pluginName: string;

View File

@@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { Trash2, RefreshCw, GitBranch, Loader2, ServerCrash, ShieldAlert, ExternalLink, BookOpen, Download, BarChart3 } from 'lucide-react'; import { Trash2, RefreshCw, GitBranch, Loader2, ServerCrash, ShieldAlert, ExternalLink, BookOpen, Download, BarChart3 } from 'lucide-react';
import { usePlugins } from '../../contexts/PluginsContext'; import { usePlugins } from '../../../contexts/PluginsContext';
import type { Plugin } from '../../contexts/PluginsContext'; import type { Plugin } from '../../../contexts/PluginsContext';
import PluginIcon from './PluginIcon'; import PluginIcon from './PluginIcon';
const STARTER_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-starter'; const STARTER_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-starter';
@@ -312,8 +312,13 @@ export default function PluginSettingsTab() {
setConfirmUninstall(name); setConfirmUninstall(name);
return; return;
} }
await uninstallPlugin(name); const result = await uninstallPlugin(name);
setConfirmUninstall(null); if (result.success) {
setConfirmUninstall(null);
} else {
setInstallError(result.error || 'Uninstall failed');
setConfirmUninstall(null);
}
}; };
const hasStarterInstalled = plugins.some((p) => p.name === 'project-stats'); const hasStarterInstalled = plugins.some((p) => p.name === 'project-stats');
@@ -350,6 +355,7 @@ export default function PluginSettingsTab() {
setInstallError(null); setInstallError(null);
}} }}
placeholder="https://github.com/user/my-plugin" placeholder="https://github.com/user/my-plugin"
aria-label="Plugin git repository URL"
className="flex-1 bg-transparent px-2 py-2.5 text-sm text-foreground placeholder:text-muted-foreground/40 focus:outline-none" className="flex-1 bg-transparent px-2 py-2.5 text-sm text-foreground placeholder:text-muted-foreground/40 focus:outline-none"
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') void handleInstall(); if (e.key === 'Enter') void handleInstall();
@@ -399,7 +405,7 @@ export default function PluginSettingsTab() {
key={plugin.name} key={plugin.name}
plugin={plugin} plugin={plugin}
index={index} index={index}
onToggle={(enabled) => void togglePlugin(plugin.name, enabled)} onToggle={(enabled) => void togglePlugin(plugin.name, enabled).then(r => { if (!r.success) setInstallError(r.error || 'Toggle failed'); })}
onUpdate={() => void handleUpdate(plugin.name)} onUpdate={() => void handleUpdate(plugin.name)}
onUninstall={() => void handleUninstall(plugin.name)} onUninstall={() => void handleUninstall(plugin.name)}
updating={updatingPlugins.has(plugin.name)} updating={updatingPlugins.has(plugin.name)}

View File

@@ -1,8 +1,8 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { useTheme } from '../../contexts/ThemeContext'; import { useTheme } from '../../../contexts/ThemeContext';
import { authenticatedFetch } from '../../utils/api'; import { authenticatedFetch } from '../../../utils/api';
import { usePlugins } from '../../contexts/PluginsContext'; import { usePlugins } from '../../../contexts/PluginsContext';
import type { Project, ProjectSession } from '../../types/app'; import type { Project, ProjectSession } from '../../../types/app';
type PluginTabContentProps = { type PluginTabContentProps = {
pluginName: string; pluginName: string;
@@ -24,10 +24,16 @@ function buildContext(
return { return {
theme: isDarkMode ? 'dark' : 'light', theme: isDarkMode ? 'dark' : 'light',
project: selectedProject project: selectedProject
? { name: selectedProject.name, path: selectedProject.fullPath || selectedProject.path } ? {
name: selectedProject.name,
path: selectedProject.fullPath || selectedProject.path || '',
}
: null, : null,
session: selectedSession session: selectedSession
? { id: selectedSession.id, title: selectedSession.title } ? {
id: selectedSession.id,
title: selectedSession.title || selectedSession.name || selectedSession.id,
}
: null, : null,
}; };
} }
@@ -44,7 +50,7 @@ export default function PluginTabContent({
// Stable refs so effects don't need context values in their dep arrays // Stable refs so effects don't need context values in their dep arrays
const contextRef = useRef<PluginContext>(buildContext(isDarkMode, selectedProject, selectedSession)); const contextRef = useRef<PluginContext>(buildContext(isDarkMode, selectedProject, selectedSession));
const contextCallbacksRef = useRef<Set<(ctx: PluginContext) => void>>(new Set()); const contextCallbacksRef = useRef<Set<(ctx: PluginContext) => void>>(new Set());
const moduleRef = useRef<any>(null); const moduleRef = useRef<any>(null);
const plugin = plugins.find(p => p.name === pluginName); const plugin = plugins.find(p => p.name === pluginName);
@@ -65,12 +71,13 @@ export default function PluginTabContent({
let active = true; let active = true;
const container = containerRef.current; const container = containerRef.current;
const entryFile = plugin?.entry ?? 'index.js'; const entryFile = plugin?.entry ?? 'index.js';
const contextCallbacks = contextCallbacksRef.current;
(async () => { (async () => {
try { try {
// Fetch the plugin JS with auth headers (Cloudflare Worker requires auth on all routes). // Fetch the plugin JS with auth headers (Cloudflare Worker requires auth on all routes).
// Then import it via a Blob URL so the browser never makes an unauthenticated request. // Then import it via a Blob URL so the browser never makes an unauthenticated request.
const assetUrl = `/api/plugins/${encodeURIComponent(pluginName)}/assets/${entryFile}`; const assetUrl = `/api/plugins/${encodeURIComponent(pluginName)}/assets/${encodeURIComponent(entryFile)}`;
const res = await authenticatedFetch(assetUrl); const res = await authenticatedFetch(assetUrl);
if (!res.ok) throw new Error(`Failed to fetch plugin (HTTP ${res.status})`); if (!res.ok) throw new Error(`Failed to fetch plugin (HTTP ${res.status})`);
const jsText = await res.text(); const jsText = await res.text();
@@ -86,8 +93,8 @@ export default function PluginTabContent({
get context(): PluginContext { return contextRef.current; }, get context(): PluginContext { return contextRef.current; },
onContextChange(cb: (ctx: PluginContext) => void): () => void { onContextChange(cb: (ctx: PluginContext) => void): () => void {
contextCallbacksRef.current.add(cb); contextCallbacks.add(cb);
return () => contextCallbacksRef.current.delete(cb); return () => contextCallbacks.delete(cb);
}, },
async rpc(method: string, path: string, body?: unknown): Promise<unknown> { async rpc(method: string, path: string, body?: unknown): Promise<unknown> {
@@ -114,7 +121,10 @@ export default function PluginTabContent({
if (!active) return; if (!active) return;
console.error(`[Plugin:${pluginName}] Failed to load:`, err); console.error(`[Plugin:${pluginName}] Failed to load:`, err);
if (containerRef.current) { if (containerRef.current) {
containerRef.current.innerHTML = `<div style="padding:16px;font-size:13px;color:#dc2626">Plugin failed to load: ${String(err)}</div>`; const errDiv = document.createElement('div');
errDiv.style.cssText = 'padding:16px;font-size:13px;color:#dc2626';
errDiv.textContent = `Plugin failed to load: ${String(err)}`;
containerRef.current.replaceChildren(errDiv);
} }
} }
})(); })();
@@ -122,7 +132,7 @@ export default function PluginTabContent({
return () => { return () => {
active = false; active = false;
try { moduleRef.current?.unmount?.(container); } catch { /* ignore */ } try { moduleRef.current?.unmount?.(container); } catch { /* ignore */ }
contextCallbacksRef.current.clear(); contextCallbacks.clear();
moduleRef.current = null; moduleRef.current = null;
}; };
}, [pluginName, plugin?.entry, plugin?.enabled]); // re-mount when plugin or enabled state changes }, [pluginName, plugin?.entry, plugin?.enabled]); // re-mount when plugin or enabled state changes

View File

@@ -10,7 +10,7 @@ import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab';
import CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSettingsTab'; import CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSettingsTab';
import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab'; import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab';
import TasksSettingsTab from '../view/tabs/tasks-settings/TasksSettingsTab'; import TasksSettingsTab from '../view/tabs/tasks-settings/TasksSettingsTab';
import PluginSettingsTab from '../../plugins/PluginSettingsTab'; import PluginSettingsTab from '../../plugins/view/PluginSettingsTab';
import { useSettingsController } from '../hooks/useSettingsController'; import { useSettingsController } from '../hooks/useSettingsController';
import type { SettingsProps } from '../types/types'; import type { SettingsProps } from '../types/types';