diff --git a/server/routes/plugins.js b/server/routes/plugins.js index e7ab7e81..ef490c45 100644 --- a/server/routes/plugins.js +++ b/server/routes/plugins.js @@ -39,6 +39,9 @@ router.get('/', (req, res) => { // GET /:name/manifest — Get single plugin manifest router.get('/:name/manifest', (req, res) => { try { + if (!/^[a-zA-Z0-9_-]+$/.test(req.params.name)) { + return res.status(400).json({ error: 'Invalid plugin name' }); + } const plugins = scanPlugins(); const plugin = plugins.find(p => p.name === req.params.name); if (!plugin) { @@ -53,6 +56,9 @@ router.get('/:name/manifest', (req, res) => { // GET /:name/assets/* — Serve plugin static files router.get('/:name/assets/*', (req, res) => { 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]; if (!assetPath) { @@ -252,7 +258,11 @@ router.all('/:name/rpc/*', async (req, res) => { }); 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). diff --git a/server/utils/plugin-loader.js b/server/utils/plugin-loader.js index 9b8cf244..e48b7686 100644 --- a/server/utils/plugin-loader.js +++ b/server/utils/plugin-loader.js @@ -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 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_SLOTS = ['tab']; @@ -92,8 +105,12 @@ export function scanPlugins() { return plugins; } + const seenNames = new Set(); + for (const entry of entries) { 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'); if (!fs.existsSync(manifestPath)) continue; @@ -106,6 +123,13 @@ export function scanPlugins() { 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 let repoUrl = null; try { @@ -119,6 +143,8 @@ export function scanPlugins() { if (repoUrl.startsWith('git@')) { repoUrl = repoUrl.replace(/^git@([^:]+):/, 'https://$1/'); } + // Strip embedded credentials (e.g. https://user:pass@host/...) + repoUrl = sanitizeRepoUrl(repoUrl); } } } catch { /* ignore */ } diff --git a/src/components/plugins/PluginSettingsTab.tsx b/src/components/plugins/PluginSettingsTab.tsx index 28d4df0b..ce959fd1 100644 --- a/src/components/plugins/PluginSettingsTab.tsx +++ b/src/components/plugins/PluginSettingsTab.tsx @@ -312,8 +312,13 @@ export default function PluginSettingsTab() { setConfirmUninstall(name); return; } - await uninstallPlugin(name); - setConfirmUninstall(null); + const result = await uninstallPlugin(name); + if (result.success) { + setConfirmUninstall(null); + } else { + setInstallError(result.error || 'Uninstall failed'); + setConfirmUninstall(null); + } }; const hasStarterInstalled = plugins.some((p) => p.name === 'project-stats'); @@ -350,6 +355,7 @@ export default function PluginSettingsTab() { setInstallError(null); }} 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" onKeyDown={(e) => { if (e.key === 'Enter') void handleInstall(); @@ -399,7 +405,7 @@ export default function PluginSettingsTab() { key={plugin.name} plugin={plugin} 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)} onUninstall={() => void handleUninstall(plugin.name)} updating={updatingPlugins.has(plugin.name)} diff --git a/src/components/plugins/PluginTabContent.tsx b/src/components/plugins/PluginTabContent.tsx index a9be3bcf..86b0daf8 100644 --- a/src/components/plugins/PluginTabContent.tsx +++ b/src/components/plugins/PluginTabContent.tsx @@ -70,7 +70,7 @@ export default function PluginTabContent({ try { // 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. - const assetUrl = `/api/plugins/${encodeURIComponent(pluginName)}/assets/${entryFile}`; + const assetUrl = `/api/plugins/${encodeURIComponent(pluginName)}/assets/${encodeURIComponent(entryFile)}`; const res = await authenticatedFetch(assetUrl); if (!res.ok) throw new Error(`Failed to fetch plugin (HTTP ${res.status})`); const jsText = await res.text(); @@ -114,7 +114,10 @@ export default function PluginTabContent({ if (!active) return; console.error(`[Plugin:${pluginName}] Failed to load:`, err); if (containerRef.current) { - containerRef.current.innerHTML = `