mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-10 00:17:43 +00:00
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
This commit is contained in:
@@ -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).
|
||||||
|
|||||||
@@ -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 */ }
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export default function PluginTabContent({
|
|||||||
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();
|
||||||
@@ -114,7 +114,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user