feat: lock down plugin runtime in sandbox + signing + approval
This commit is contained in:
@@ -3,6 +3,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers';
|
||||
import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot';
|
||||
|
||||
interface ConfigField {
|
||||
type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
|
||||
@@ -286,6 +288,27 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
|
||||
<p className="text-sm text-muted-foreground">This plugin does not declare any configuration settings.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PluginAdminSection pluginId={pluginId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the plugin's own `admin-plugin-page` slot, if the plugin offers
|
||||
* one. Sandboxed plugins ship a React component under `slots['admin-plugin-page']`
|
||||
* and the host gives it a dedicated iframe inside the admin panel.
|
||||
*/
|
||||
function PluginAdminSection({ pluginId }: { pluginId: string }) {
|
||||
const offers = usePluginSlotOffers('admin-plugin-page');
|
||||
const offer = offers.find((o) => o.pluginId === pluginId);
|
||||
if (!offer) return null;
|
||||
return (
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<div className="bg-muted/40 px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Plugin admin panel
|
||||
</div>
|
||||
<PluginIframeSlot pluginId={pluginId} slot="admin-plugin-page" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import {
|
||||
sanitizeFrameOrigins,
|
||||
sanitizeHttpOrigins,
|
||||
sanitizeApiPostPaths,
|
||||
invalidateFrameOriginsCache,
|
||||
} from '@/lib/admin/csp-frame-origins';
|
||||
import JSZip from 'jszip';
|
||||
@@ -266,6 +267,18 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const declaredApiPostPaths = sanitizeApiPostPaths(manifest.apiPostPaths);
|
||||
const droppedApiPostPaths = Array.isArray(manifest.apiPostPaths)
|
||||
? (manifest.apiPostPaths as unknown[]).filter(
|
||||
(v) => typeof v !== 'string' || !declaredApiPostPaths.includes(v),
|
||||
)
|
||||
: [];
|
||||
if (droppedApiPostPaths.length > 0) {
|
||||
warnings.push(
|
||||
`Ignored invalid apiPostPaths: ${droppedApiPostPaths.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const plugin: ServerPlugin = {
|
||||
id: (manifest.id as string) || slug,
|
||||
name: (manifest.name as string) || slug,
|
||||
@@ -290,11 +303,14 @@ export async function POST(request: NextRequest) {
|
||||
...(declaredHttpOrigins.length > 0
|
||||
? { httpOrigins: declaredHttpOrigins }
|
||||
: {}),
|
||||
...(declaredApiPostPaths.length > 0
|
||||
? { apiPostPaths: declaredApiPostPaths }
|
||||
: {}),
|
||||
};
|
||||
|
||||
await savePlugin(plugin, code);
|
||||
invalidateFrameOriginsCache();
|
||||
await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins }, ip);
|
||||
await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins, apiPostPaths: declaredApiPostPaths }, ip);
|
||||
|
||||
return NextResponse.json({ success: true, plugin, warnings });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
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 { listApprovals, decideApproval, revokeApproval } from '@/lib/admin/plugin-approvals';
|
||||
|
||||
/**
|
||||
* Admin-protected CRUD for the per-(pluginId, bundleHash) approval table.
|
||||
*
|
||||
* GET /api/admin/plugin-approvals → list all entries
|
||||
* POST /api/admin/plugin-approvals → { pluginId, bundleHash, decision: 'approved'|'denied' }
|
||||
* DELETE /api/admin/plugin-approvals?pluginId=…&bundleHash=… → revoke
|
||||
*/
|
||||
|
||||
function isValidId(s: unknown): s is string {
|
||||
return typeof s === 'string' && /^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(s) && s.length <= 64;
|
||||
}
|
||||
function isValidHash(s: unknown): s is string {
|
||||
return typeof s === 'string' && /^[a-f0-9]{16,128}$/i.test(s);
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
if ('error' in result) return result.error;
|
||||
const entries = await listApprovals();
|
||||
return NextResponse.json({ entries }, { headers: { 'Cache-Control': 'no-store' } });
|
||||
} catch (err) {
|
||||
logger.error('plugin-approvals GET', { error: err instanceof Error ? err.message : String(err) });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
if ('error' in result) return result.error;
|
||||
// AdminSessionPayload carries only role/iat/exp; we use a stable label
|
||||
// for the audit trail rather than a per-user identity.
|
||||
const adminUser = 'admin';
|
||||
void result;
|
||||
const ip = getClientIP(request);
|
||||
|
||||
let body: unknown;
|
||||
try { body = await request.json(); } catch { body = null; }
|
||||
const b = (body ?? {}) as { pluginId?: unknown; bundleHash?: unknown; decision?: unknown };
|
||||
if (!isValidId(b.pluginId) || !isValidHash(b.bundleHash)) {
|
||||
return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 });
|
||||
}
|
||||
if (b.decision !== 'approved' && b.decision !== 'denied') {
|
||||
return NextResponse.json({ error: 'decision must be "approved" or "denied"' }, { status: 400 });
|
||||
}
|
||||
|
||||
const entry = await decideApproval(b.pluginId, b.bundleHash, b.decision, adminUser);
|
||||
await auditLog('plugin.approval', { pluginId: entry.pluginId, bundleHash: entry.bundleHash, decision: entry.status }, ip);
|
||||
return NextResponse.json({ entry });
|
||||
} catch (err) {
|
||||
logger.error('plugin-approvals POST', { error: err instanceof Error ? err.message : String(err) });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
if ('error' in result) return result.error;
|
||||
// AdminSessionPayload carries only role/iat/exp; we use a stable label
|
||||
// for the audit trail rather than a per-user identity.
|
||||
const adminUser = 'admin';
|
||||
void result;
|
||||
const ip = getClientIP(request);
|
||||
|
||||
const pluginId = request.nextUrl.searchParams.get('pluginId');
|
||||
const bundleHash = request.nextUrl.searchParams.get('bundleHash');
|
||||
if (!isValidId(pluginId) || !isValidHash(bundleHash)) {
|
||||
return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 });
|
||||
}
|
||||
await revokeApproval(pluginId, bundleHash);
|
||||
await auditLog('plugin.approval.revoke', { pluginId, bundleHash, by: adminUser }, ip);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
logger.error('plugin-approvals DELETE', { error: err instanceof Error ? err.message : String(err) });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
|
||||
import { getDevPlugin, readDevBundle } from '@/lib/admin/plugin-dev';
|
||||
import { signBytes } from '@/lib/admin/plugin-signing';
|
||||
|
||||
async function safeSign(code: string): Promise<string | null> {
|
||||
try { return await signBytes(code); } catch { return null; }
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/plugins/[id]/bundle - Serve plugin JS bundle
|
||||
@@ -25,14 +30,15 @@ export async function GET(
|
||||
const devEntry = await getDevPlugin(id);
|
||||
if (devEntry) {
|
||||
const code = await readDevBundle(devEntry);
|
||||
return new NextResponse(code, {
|
||||
headers: {
|
||||
'Content-Type': 'application/javascript; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
'ETag': `"${devEntry.plugin.bundleHash}"`,
|
||||
'Content-Length': String(Buffer.byteLength(code, 'utf-8')),
|
||||
},
|
||||
});
|
||||
const signature = await safeSign(code);
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/javascript; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
'ETag': `"${devEntry.plugin.bundleHash}"`,
|
||||
'Content-Length': String(Buffer.byteLength(code, 'utf-8')),
|
||||
};
|
||||
if (signature) headers['X-Bundle-Signature'] = signature;
|
||||
return new NextResponse(code, { headers });
|
||||
}
|
||||
|
||||
const plugin = await getPlugin(id);
|
||||
@@ -59,6 +65,9 @@ export async function GET(
|
||||
};
|
||||
if (etag) headers['ETag'] = etag;
|
||||
|
||||
const signature = await safeSign(code);
|
||||
if (signature) headers['X-Bundle-Signature'] = signature;
|
||||
|
||||
if (etag && request.headers.get('if-none-match') === etag) {
|
||||
return new NextResponse(null, { status: 304, headers });
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { listDevPlugins } from '@/lib/admin/plugin-dev';
|
||||
import {
|
||||
sanitizeFrameOrigins,
|
||||
sanitizeHttpOrigins,
|
||||
sanitizeApiPostPaths,
|
||||
invalidateFrameOriginsCache,
|
||||
} from '@/lib/admin/csp-frame-origins';
|
||||
|
||||
@@ -172,6 +173,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
|
||||
const declaredHttpOrigins = sanitizeHttpOrigins(manifest.httpOrigins);
|
||||
const declaredApiPostPaths = sanitizeApiPostPaths(manifest.apiPostPaths);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const plugin: ServerPlugin = {
|
||||
@@ -196,13 +198,16 @@ export async function POST(request: NextRequest) {
|
||||
...(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 }, ip);
|
||||
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins, apiPostPaths: declaredApiPostPaths }, ip);
|
||||
|
||||
return NextResponse.json({ plugin });
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { decryptSession } from '@/lib/auth/crypto';
|
||||
import { sessionCookieName } from '@/lib/auth/session-cookie';
|
||||
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
|
||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getApprovalStatus, requestApproval, type ApprovalEntry } from '@/lib/admin/plugin-approvals';
|
||||
|
||||
/**
|
||||
* GET /api/plugin-approval-status?pluginId=X&bundleHash=Y
|
||||
*
|
||||
* Any logged-in user may query the server-side approval state for a plugin
|
||||
* they want to enable. The client uses this BEFORE running `enablePlugin`
|
||||
* when the `requirePluginApproval` policy is set.
|
||||
*
|
||||
* POST same path with body `{ pluginId, bundleHash, manifest }` creates a
|
||||
* pending approval entry (or returns the existing one).
|
||||
*/
|
||||
|
||||
async function resolveUsername(): Promise<string | null> {
|
||||
const cookieStore = await cookies();
|
||||
for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) {
|
||||
const token = cookieStore.get(sessionCookieName(slot))?.value;
|
||||
if (token) {
|
||||
const sess = decryptSession(token);
|
||||
if (sess?.username) return sess.username;
|
||||
}
|
||||
const ctx = readStalwartAuthContextFromStore(cookieStore, slot);
|
||||
if (ctx?.username) return ctx.username;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isValidId(s: unknown): s is string {
|
||||
return typeof s === 'string' && /^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(s) && s.length <= 64;
|
||||
}
|
||||
function isValidHash(s: unknown): s is string {
|
||||
return typeof s === 'string' && /^[a-f0-9]{16,128}$/i.test(s);
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const username = await resolveUsername();
|
||||
if (!username) return NextResponse.json({ error: 'unauthenticated' }, { status: 401 });
|
||||
|
||||
const pluginId = request.nextUrl.searchParams.get('pluginId');
|
||||
const bundleHash = request.nextUrl.searchParams.get('bundleHash');
|
||||
if (!isValidId(pluginId) || !isValidHash(bundleHash)) {
|
||||
return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 });
|
||||
}
|
||||
const status = await getApprovalStatus(pluginId, bundleHash);
|
||||
return NextResponse.json(status, { headers: { 'Cache-Control': 'no-store' } });
|
||||
} catch (err) {
|
||||
logger.error('plugin-approval-status GET', { error: err instanceof Error ? err.message : String(err) });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const username = await resolveUsername();
|
||||
if (!username) return NextResponse.json({ error: 'unauthenticated' }, { status: 401 });
|
||||
|
||||
let body: unknown;
|
||||
try { body = await request.json(); } catch { body = null; }
|
||||
const b = (body ?? {}) as { pluginId?: unknown; bundleHash?: unknown; manifest?: unknown };
|
||||
if (!isValidId(b.pluginId) || !isValidHash(b.bundleHash)) {
|
||||
return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 });
|
||||
}
|
||||
|
||||
const m = (b.manifest ?? {}) as Record<string, unknown>;
|
||||
const manifest: ApprovalEntry['manifest'] = {
|
||||
name: typeof m.name === 'string' ? m.name.slice(0, 200) : undefined,
|
||||
version: typeof m.version === 'string' ? m.version.slice(0, 64) : undefined,
|
||||
author: typeof m.author === 'string' ? m.author.slice(0, 200) : undefined,
|
||||
description: typeof m.description === 'string' ? m.description.slice(0, 500) : undefined,
|
||||
permissions: Array.isArray(m.permissions) ? (m.permissions as unknown[]).filter((x): x is string => typeof x === 'string').slice(0, 50) : undefined,
|
||||
httpOrigins: Array.isArray(m.httpOrigins) ? (m.httpOrigins as unknown[]).filter((x): x is string => typeof x === 'string').slice(0, 20) : undefined,
|
||||
apiPostPaths: Array.isArray(m.apiPostPaths) ? (m.apiPostPaths as unknown[]).filter((x): x is string => typeof x === 'string').slice(0, 20) : undefined,
|
||||
};
|
||||
|
||||
const entry = await requestApproval(b.pluginId as string, b.bundleHash as string, manifest, username);
|
||||
return NextResponse.json({ status: entry.status, requestedAt: entry.requestedAt, decidedAt: entry.decidedAt });
|
||||
} catch (err) {
|
||||
logger.error('plugin-approval-status POST', { error: err instanceof Error ? err.message : String(err) });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getPublicKeyBase64 } from '@/lib/admin/plugin-signing';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
/**
|
||||
* GET /api/plugin-signing-pubkey
|
||||
*
|
||||
* Returns the host's Ed25519 public key (base64-encoded raw 32 bytes) so the
|
||||
* sandboxed plugin loader can verify bundle signatures before evaluation.
|
||||
* Public — every logged-in user needs to fetch it on app boot.
|
||||
*
|
||||
* The response is long-cache-eligible (the key rotates only when an operator
|
||||
* deletes the on-disk PEM), but we keep it `no-store` for simplicity. The
|
||||
* client caches the result in memory for the lifetime of the page.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const publicKey = await getPublicKeyBase64();
|
||||
return NextResponse.json(
|
||||
{ algorithm: 'ed25519', publicKey },
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error('[plugin-signing-pubkey] load failed', { error: err instanceof Error ? err.message : String(err) });
|
||||
return NextResponse.json({ error: 'Signing key unavailable' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,8 @@ export async function GET() {
|
||||
dev: p.dev,
|
||||
// Surface so clients can enforce api.http.fetch origin allowlists.
|
||||
httpOrigins: p.httpOrigins,
|
||||
// Surface so clients can enforce api.http.post path allowlists.
|
||||
apiPostPaths: p.apiPostPaths,
|
||||
// Per-user settings schema, captured from the manifest at upload/load
|
||||
// time so the client can render the settings UI without re-parsing.
|
||||
settingsSchema: p.settingsSchema,
|
||||
|
||||
Reference in New Issue
Block a user