mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
The Settings upload, the admin upload, the marketplace install and the marketplace preview each parsed the ZIP and manifest with their own copy of the code, and the copies had drifted. A `permissions` string skipped validation and was then matched by substring, a small ZIP could inflate to hundreds of megabytes on the server, the Settings upload dropped `locales` and took origin lists unchecked, and settings schemas were never checked at all. All of them now go through `lib/extension-package.ts`: - every file is inflated through a byte cap, with a limit on entries - one manifest check: types, permissions, entrypoint, version, minAppVersion, settingsSchema, configSchema, locales and origin lists - only checked fields are stored
205 lines
7.7 KiB
TypeScript
205 lines
7.7 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 { invalidateFrameOriginsCache } from '@/lib/admin/csp-frame-origins';
|
|
import { ExtensionPackageError, readPluginPackage, suspiciousCode } from '@/lib/extension-package';
|
|
import { MAX_PLUGIN_SIZE } from '@/lib/plugin-types';
|
|
|
|
/**
|
|
* 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 });
|
|
}
|
|
|
|
let pkg;
|
|
try {
|
|
pkg = await readPluginPackage(await file.arrayBuffer(), {
|
|
appVersion: process.env.NEXT_PUBLIC_APP_VERSION,
|
|
});
|
|
} catch (err) {
|
|
if (err instanceof ExtensionPackageError) {
|
|
return NextResponse.json({ error: err.message }, { status: 400 });
|
|
}
|
|
throw err;
|
|
}
|
|
const { manifest, code } = pkg;
|
|
|
|
// Admin uploads reach every user, so the patterns a user upload only
|
|
// warns about are refused here.
|
|
const suspicious = suspiciousCode(code);
|
|
if (suspicious.length > 0) {
|
|
return NextResponse.json(
|
|
{ error: `Plugin rejected: contains ${suspicious.join(', ')}. These patterns are not allowed for security reasons.` },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
const now = new Date().toISOString();
|
|
const plugin: ServerPlugin = {
|
|
id: manifest.id,
|
|
name: manifest.name,
|
|
version: manifest.version,
|
|
author: manifest.author,
|
|
description: manifest.description,
|
|
type: manifest.type,
|
|
...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}),
|
|
permissions: manifest.permissions,
|
|
entrypoint: manifest.entrypoint,
|
|
enabled: true,
|
|
...(manifest.configSchema ? { configSchema: manifest.configSchema } : {}),
|
|
...(manifest.settingsSchema ? { settingsSchema: manifest.settingsSchema } : {}),
|
|
...(manifest.locales ? { locales: manifest.locales } : {}),
|
|
...(manifest.frameOrigins ? { frameOrigins: manifest.frameOrigins } : {}),
|
|
...(manifest.httpOrigins ? { httpOrigins: manifest.httpOrigins } : {}),
|
|
...(manifest.apiPostPaths ? { apiPostPaths: manifest.apiPostPaths } : {}),
|
|
installedAt: now,
|
|
updatedAt: now,
|
|
};
|
|
const declaredFrameOrigins = plugin.frameOrigins ?? [];
|
|
const declaredHttpOrigins = plugin.httpOrigins ?? [];
|
|
const declaredApiPostPaths = plugin.apiPostPaths ?? [];
|
|
|
|
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);
|
|
|
|
return NextResponse.json({ plugin, warnings: pkg.warnings });
|
|
} 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 });
|
|
}
|
|
}
|