The overrideWarnings escape hatch added alongside the bundle scan was API-only: an admin uploading a crypto plugin through the web form hit a 400 with canOverride and had no way to act on it, which left S/MIME and PGP bundles uninstallable through the UI. Hold the rejected file client-side and show the findings — pattern per file — with "Install anyway" and "Cancel". Proceeding re-posts the same file with overrideWarnings, so the decision stays explicit and lands in the audit log. The route now echoes accepted findings back on success so the confirmation says how many were waved through rather than reporting a bare install. Also replaces a dead `data.warnings` read with the live `findings` field; the route never returned `warnings` on success, so that branch never ran. Completes B-01. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
347 lines
14 KiB
TypeScript
347 lines
14 KiB
TypeScript
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<string, unknown>;
|
|
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 });
|
|
}
|
|
}
|