Files
SRCmail/app/api/admin/plugins/route.ts
T
Bernd RodlerandClaude Opus 4.8 d91db37b34 fix(plugins): scan all bundle scripts, allow audited scanner override
Two problems with the upload scanner, pulling in opposite directions.

It only scanned the entrypoint, so a bundle with `eval()` in a second
file passed outright — verified against a synthetic bundle whose
vendor/openpgp.js tripped three patterns while index.js stayed clean.

At the same time, a hard 400 on eval()/new Function()/innerHTML= makes
every crypto plugin uninstallable: minified openpgp.js and pkijs
legitimately contain all three. That blocks S/MIME and PGP entirely.

Scan every .js/.mjs in the bundle and return structured findings
({file, patterns[]}) plus canOverride, so the admin can see exactly what
tripped and where. An explicit overrideWarnings=true proceeds and writes
a plugin.install.scan_override audit entry recording which patterns in
which files were accepted — not merely that an override happened.

This route is already admin-authenticated, so the scan is defence in
depth against an accidental or compromised upload, not a trust boundary.
Treating it as the latter is what made crypto plugins uninstallable.

Also log the B-04 and B-01 divergences in vnc/VNC-CHANGES.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 08:57:28 +02:00

345 lines
13 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 });
}
return NextResponse.json({ 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 });
}
}