import { NextRequest, NextResponse } from 'next/server'; import { requireAdminAuth, getClientIP } from '@/lib/admin/session'; import { auditLog } from '@/lib/admin/audit'; import { logger } from '@/lib/logger'; import { getPluginRegistry, savePlugin, deletePlugin as removePlugin, type ServerPlugin, } from '@/lib/admin/plugin-registry'; import { listDevPlugins } from '@/lib/admin/plugin-dev'; import { sanitizeFrameOrigins, sanitizeHttpOrigins, sanitizeApiPostPaths, invalidateFrameOriginsCache, } from '@/lib/admin/csp-frame-origins'; // Server-side extraction using the same validation logic // ZIP parsing needs to happen on the server for admin-uploaded plugins import JSZip from 'jszip'; import { MAX_PLUGIN_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types'; const SUSPICIOUS_JS_PATTERNS = [ { pattern: /\beval\s*\(/g, label: 'eval()' }, { pattern: /\bnew\s+Function\s*\(/g, label: 'new Function()' }, { pattern: /document\.cookie/g, label: 'document.cookie' }, { pattern: /document\.write/g, label: 'document.write' }, { pattern: /innerHTML\s*=/g, label: 'innerHTML assignment' }, ]; /** * GET /api/admin/plugins - List all admin-managed plugins */ export async function GET(request: NextRequest) { try { const result = await requireAdminAuth(request); if ('error' in result) return result.error; const [registry, devEntries] = await Promise.all([ getPluginRegistry(), listDevPlugins(), ]); // Dev plugins win on id collision so admins see what users actually load. const devIds = new Set(devEntries.map(e => e.plugin.id)); const merged = [ ...devEntries.map(e => ({ ...e.plugin, dev: true as const })), ...registry.plugins .filter(p => !devIds.has(p.id)) .map(p => ({ ...p, dev: false as const })), ]; return NextResponse.json(merged, { headers: { 'Cache-Control': 'no-store' }, }); } catch (error) { logger.error('Plugin list error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } } /** * POST /api/admin/plugins - Upload and install a plugin ZIP */ export async function POST(request: NextRequest) { try { const result = await requireAdminAuth(request); if ('error' in result) return result.error; const ip = getClientIP(request); const formData = await request.formData(); const file = formData.get('file') as File | null; if (!file) { return NextResponse.json({ error: 'Missing file' }, { status: 400 }); } if (file.size > MAX_PLUGIN_SIZE) { return NextResponse.json({ error: 'Plugin ZIP exceeds 5 MB size limit' }, { status: 400 }); } // Extract and validate ZIP let zip: JSZip; try { const buffer = await file.arrayBuffer(); zip = await JSZip.loadAsync(buffer); } catch { return NextResponse.json({ error: 'Invalid ZIP file' }, { status: 400 }); } // Find root const entries = Object.keys(zip.files); const topDirs = new Set(entries.map(e => e.split('/')[0])); let root = ''; if (topDirs.size === 1) { const dir = [...topDirs][0]; if (zip.files[dir + '/'] || entries.some(e => e.startsWith(dir + '/'))) { root = dir + '/'; } } // Read manifest const manifestFile = zip.file(root + 'manifest.json'); if (!manifestFile) { return NextResponse.json({ error: 'Missing manifest.json' }, { status: 400 }); } let manifest: Record; try { manifest = JSON.parse(await manifestFile.async('string')); } catch { return NextResponse.json({ error: 'Invalid manifest.json' }, { status: 400 }); } // Validate manifest const errors: string[] = []; if (!manifest.id || typeof manifest.id !== 'string') errors.push('Missing or invalid "id"'); if (!manifest.name || typeof manifest.name !== 'string') errors.push('Missing or invalid "name"'); if (!manifest.version || typeof manifest.version !== 'string') errors.push('Missing or invalid "version"'); if (!manifest.author || typeof manifest.author !== 'string') errors.push('Missing or invalid "author"'); if (!manifest.entrypoint || typeof manifest.entrypoint !== 'string') errors.push('Missing or invalid "entrypoint"'); const validTypes = ['ui-extension', 'sidebar-app', 'hook']; if (!validTypes.includes(manifest.type as string)) { errors.push(`Invalid type. Must be one of: ${validTypes.join(', ')}`); } if (manifest.id && typeof manifest.id === 'string' && !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(manifest.id)) { errors.push('ID must be lowercase alphanumeric with hyphens, min 2 chars'); } if (manifest.permissions && Array.isArray(manifest.permissions)) { const validPerms = new Set(ALL_PERMISSIONS as readonly string[]); const unknown = (manifest.permissions as string[]).filter(p => !validPerms.has(p)); if (unknown.length > 0) errors.push(`Unknown permissions: ${unknown.join(', ')}`); } if (errors.length > 0) { return NextResponse.json({ error: errors.join('; ') }, { status: 400 }); } // Check file extensions for (const [filePath, entry] of Object.entries(zip.files)) { if (entry.dir) continue; const ext = filePath.lastIndexOf('.') >= 0 ? filePath.slice(filePath.lastIndexOf('.')).toLowerCase() : ''; if (ext && !ALLOWED_PLUGIN_FILES.has(ext)) { errors.push(`Disallowed file type: ${filePath}`); } } if (errors.length > 0) { return NextResponse.json({ error: errors.join('; ') }, { status: 400 }); } // Read entrypoint code const entryFile = zip.file(root + (manifest.entrypoint as string)); if (!entryFile) { return NextResponse.json({ error: `Missing entrypoint: ${manifest.entrypoint}` }, { status: 400 }); } const code = await entryFile.async('string'); // Security: scan for dangerous JS patterns across EVERY script in the // bundle, not just the entrypoint - a second .js file was previously never // looked at. // // The result is a reviewable finding rather than an unconditional reject. // Minified crypto libraries (openpgp.js, pkijs) legitimately contain these // patterns, so a hard block makes S/MIME and PGP plugins uninstallable. // This route is already admin-authenticated, so the scan is defence in // depth against an accidental or compromised upload, not a trust boundary: // an admin may proceed with `overrideWarnings`, and the override is // recorded in the audit log with the exact findings. const findings: Array<{ file: string; patterns: string[] }> = []; for (const [filePath, entry] of Object.entries(zip.files)) { if (entry.dir) continue; const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase(); if (ext !== '.js' && ext !== '.mjs') continue; const source = filePath === root + (manifest.entrypoint as string) ? code : await entry.async('string'); const hits: string[] = []; for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) { if (pattern.test(source)) hits.push(label); pattern.lastIndex = 0; } if (hits.length > 0) { findings.push({ file: filePath.slice(root.length), patterns: hits }); } } const overrideWarnings = formData.get('overrideWarnings') === 'true'; if (findings.length > 0 && !overrideWarnings) { const summary = findings .map(f => `${f.file}: ${f.patterns.join(', ')}`) .join('; '); return NextResponse.json( { error: `Plugin rejected: ${summary}. Review the bundle; if these are expected ` + `(e.g. a vendored crypto library), re-upload with "overrideWarnings" to proceed.`, findings, canOverride: true, }, { status: 400 }, ); } const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins); const declaredHttpOrigins = sanitizeHttpOrigins(manifest.httpOrigins); const declaredApiPostPaths = sanitizeApiPostPaths(manifest.apiPostPaths); const now = new Date().toISOString(); const plugin: ServerPlugin = { id: manifest.id as string, name: manifest.name as string, version: manifest.version as string, author: manifest.author as string, description: (manifest.description as string) || '', type: manifest.type as string, ...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}), permissions: (manifest.permissions as string[]) || [], entrypoint: manifest.entrypoint as string, enabled: true, ...(manifest.configSchema && typeof manifest.configSchema === 'object' ? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] } : {}), ...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object' ? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] } : {}), ...(manifest.locales && typeof manifest.locales === 'object' ? { locales: manifest.locales as ServerPlugin['locales'] } : {}), ...(declaredFrameOrigins.length > 0 ? { frameOrigins: declaredFrameOrigins } : {}), ...(declaredHttpOrigins.length > 0 ? { httpOrigins: declaredHttpOrigins } : {}), ...(declaredApiPostPaths.length > 0 ? { apiPostPaths: declaredApiPostPaths } : {}), installedAt: now, updatedAt: now, }; await savePlugin(plugin, code); invalidateFrameOriginsCache(); await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins, apiPostPaths: declaredApiPostPaths }, ip); if (findings.length > 0) { // Record WHAT was waved through, not merely that an override happened - // otherwise the audit trail can't answer "which patterns did we accept?". await auditLog( 'plugin.install.scan_override', { id: plugin.id, version: plugin.version, findings }, ip, ); logger.warn('Plugin installed with scanner override', { id: plugin.id, findings }); } // Echo accepted findings back so the admin UI can confirm exactly what was // waved through, rather than reporting a bare success. return NextResponse.json(findings.length > 0 ? { plugin, findings } : { plugin }); } catch (error) { logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } } /** * PATCH /api/admin/plugins - Update plugin metadata (enable/disable) * Body: { id: string, enabled: boolean } */ export async function PATCH(request: NextRequest) { try { const result = await requireAdminAuth(request); if ('error' in result) return result.error; const ip = getClientIP(request); const { id, enabled, forceEnabled } = await request.json(); if (!id || typeof id !== 'string') { return NextResponse.json({ error: 'Missing plugin id' }, { status: 400 }); } if (typeof enabled !== 'boolean' && typeof forceEnabled !== 'boolean') { return NextResponse.json({ error: 'enabled or forceEnabled must be a boolean' }, { status: 400 }); } const updates: { enabled?: boolean; forceEnabled?: boolean } = {}; if (typeof enabled === 'boolean') updates.enabled = enabled; if (typeof forceEnabled === 'boolean') updates.forceEnabled = forceEnabled; const { updatePluginMeta } = await import('@/lib/admin/plugin-registry'); let updated = await updatePluginMeta(id, updates); if (!updated) { // Dev plugins (PLUGIN_DEV_DIR) aren't in the persisted registry, but // forceEnabled is canonical-stored in policy.forceEnabledPlugins on the // client. Skip the registry write and return the live dev plugin so the // policy save path can proceed. const devEntries = await listDevPlugins(); const devEntry = devEntries.find(e => e.plugin.id === id); if (!devEntry) { return NextResponse.json({ error: 'Plugin not found' }, { status: 404 }); } updated = { ...devEntry.plugin, ...updates }; } // Enable/disable changes the set of plugins contributing frame origins. if (typeof updates.enabled === 'boolean' || typeof updates.forceEnabled === 'boolean') { invalidateFrameOriginsCache(); } await auditLog('plugin.update', { id, ...updates }, ip); return NextResponse.json({ plugin: updated }); } catch (error) { logger.error('Plugin update error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } } /** * DELETE /api/admin/plugins - Remove a plugin * Body: { id: string } */ export async function DELETE(request: NextRequest) { try { const result = await requireAdminAuth(request); if ('error' in result) return result.error; const ip = getClientIP(request); const { id } = await request.json(); if (!id || typeof id !== 'string') { return NextResponse.json({ error: 'Missing plugin id' }, { status: 400 }); } const removed = await removePlugin(id); if (!removed) { return NextResponse.json({ error: 'Plugin not found' }, { status: 404 }); } invalidateFrameOriginsCache(); await auditLog('plugin.delete', { id }, ip); return NextResponse.json({ success: true }); } catch (error) { logger.error('Plugin delete error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } }