feat: lock down plugin runtime in sandbox + signing + approval
This commit is contained in:
@@ -3,6 +3,8 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react';
|
import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers';
|
||||||
|
import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot';
|
||||||
|
|
||||||
interface ConfigField {
|
interface ConfigField {
|
||||||
type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
|
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>
|
<p className="text-sm text-muted-foreground">This plugin does not declare any configuration settings.</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
sanitizeFrameOrigins,
|
sanitizeFrameOrigins,
|
||||||
sanitizeHttpOrigins,
|
sanitizeHttpOrigins,
|
||||||
|
sanitizeApiPostPaths,
|
||||||
invalidateFrameOriginsCache,
|
invalidateFrameOriginsCache,
|
||||||
} from '@/lib/admin/csp-frame-origins';
|
} from '@/lib/admin/csp-frame-origins';
|
||||||
import JSZip from 'jszip';
|
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 = {
|
const plugin: ServerPlugin = {
|
||||||
id: (manifest.id as string) || slug,
|
id: (manifest.id as string) || slug,
|
||||||
name: (manifest.name as string) || slug,
|
name: (manifest.name as string) || slug,
|
||||||
@@ -290,11 +303,14 @@ export async function POST(request: NextRequest) {
|
|||||||
...(declaredHttpOrigins.length > 0
|
...(declaredHttpOrigins.length > 0
|
||||||
? { httpOrigins: declaredHttpOrigins }
|
? { httpOrigins: declaredHttpOrigins }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(declaredApiPostPaths.length > 0
|
||||||
|
? { apiPostPaths: declaredApiPostPaths }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
await savePlugin(plugin, code);
|
await savePlugin(plugin, code);
|
||||||
invalidateFrameOriginsCache();
|
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 });
|
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 { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
|
import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
|
||||||
import { getDevPlugin, readDevBundle } from '@/lib/admin/plugin-dev';
|
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
|
* GET /api/admin/plugins/[id]/bundle - Serve plugin JS bundle
|
||||||
@@ -25,14 +30,15 @@ export async function GET(
|
|||||||
const devEntry = await getDevPlugin(id);
|
const devEntry = await getDevPlugin(id);
|
||||||
if (devEntry) {
|
if (devEntry) {
|
||||||
const code = await readDevBundle(devEntry);
|
const code = await readDevBundle(devEntry);
|
||||||
return new NextResponse(code, {
|
const signature = await safeSign(code);
|
||||||
headers: {
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': 'application/javascript; charset=utf-8',
|
'Content-Type': 'application/javascript; charset=utf-8',
|
||||||
'Cache-Control': 'no-store',
|
'Cache-Control': 'no-store',
|
||||||
'ETag': `"${devEntry.plugin.bundleHash}"`,
|
'ETag': `"${devEntry.plugin.bundleHash}"`,
|
||||||
'Content-Length': String(Buffer.byteLength(code, 'utf-8')),
|
'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);
|
const plugin = await getPlugin(id);
|
||||||
@@ -59,6 +65,9 @@ export async function GET(
|
|||||||
};
|
};
|
||||||
if (etag) headers['ETag'] = etag;
|
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) {
|
if (etag && request.headers.get('if-none-match') === etag) {
|
||||||
return new NextResponse(null, { status: 304, headers });
|
return new NextResponse(null, { status: 304, headers });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { listDevPlugins } from '@/lib/admin/plugin-dev';
|
|||||||
import {
|
import {
|
||||||
sanitizeFrameOrigins,
|
sanitizeFrameOrigins,
|
||||||
sanitizeHttpOrigins,
|
sanitizeHttpOrigins,
|
||||||
|
sanitizeApiPostPaths,
|
||||||
invalidateFrameOriginsCache,
|
invalidateFrameOriginsCache,
|
||||||
} from '@/lib/admin/csp-frame-origins';
|
} from '@/lib/admin/csp-frame-origins';
|
||||||
|
|
||||||
@@ -172,6 +173,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
|
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
|
||||||
const declaredHttpOrigins = sanitizeHttpOrigins(manifest.httpOrigins);
|
const declaredHttpOrigins = sanitizeHttpOrigins(manifest.httpOrigins);
|
||||||
|
const declaredApiPostPaths = sanitizeApiPostPaths(manifest.apiPostPaths);
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const plugin: ServerPlugin = {
|
const plugin: ServerPlugin = {
|
||||||
@@ -196,13 +198,16 @@ export async function POST(request: NextRequest) {
|
|||||||
...(declaredHttpOrigins.length > 0
|
...(declaredHttpOrigins.length > 0
|
||||||
? { httpOrigins: declaredHttpOrigins }
|
? { httpOrigins: declaredHttpOrigins }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(declaredApiPostPaths.length > 0
|
||||||
|
? { apiPostPaths: declaredApiPostPaths }
|
||||||
|
: {}),
|
||||||
installedAt: now,
|
installedAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
await savePlugin(plugin, code);
|
await savePlugin(plugin, code);
|
||||||
invalidateFrameOriginsCache();
|
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 });
|
return NextResponse.json({ plugin });
|
||||||
} catch (error) {
|
} 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,
|
dev: p.dev,
|
||||||
// Surface so clients can enforce api.http.fetch origin allowlists.
|
// Surface so clients can enforce api.http.fetch origin allowlists.
|
||||||
httpOrigins: p.httpOrigins,
|
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
|
// Per-user settings schema, captured from the manifest at upload/load
|
||||||
// time so the client can render the settings UI without re-parsing.
|
// time so the client can render the settings UI without re-parsing.
|
||||||
settingsSchema: p.settingsSchema,
|
settingsSchema: p.settingsSchema,
|
||||||
|
|||||||
@@ -60,6 +60,36 @@ export function sanitizeFrameOrigins(input: unknown): string[] {
|
|||||||
export const sanitizeHttpOrigins = sanitizeFrameOrigins;
|
export const sanitizeHttpOrigins = sanitizeFrameOrigins;
|
||||||
export const isValidHttpOrigin = isValidFrameOrigin;
|
export const isValidHttpOrigin = isValidFrameOrigin;
|
||||||
|
|
||||||
|
// ─── apiPostPaths (manifest field) ────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates an `/api/...` path entry. Must start with `/api/`, contain only
|
||||||
|
* URL-path-safe characters, and have no `..` segment. The trailing slash is
|
||||||
|
* meaningful (treated as a prefix at enforcement time).
|
||||||
|
*/
|
||||||
|
export function isValidApiPostPath(path: unknown): path is string {
|
||||||
|
if (typeof path !== 'string') return false;
|
||||||
|
if (path.length === 0 || path.length > 200) return false;
|
||||||
|
if (!path.startsWith('/api/')) return false;
|
||||||
|
if (path.includes('..')) return false;
|
||||||
|
if (/[\s'"`;,()?#]/.test(path)) return false;
|
||||||
|
if (!/^[/A-Za-z0-9._\-]+$/.test(path)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeApiPostPaths(input: unknown): string[] {
|
||||||
|
if (!Array.isArray(input)) return [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const value of input) {
|
||||||
|
if (!isValidApiPostPath(value)) continue;
|
||||||
|
if (seen.has(value)) continue;
|
||||||
|
seen.add(value);
|
||||||
|
out.push(value);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// In-memory cache. The proxy fires on every page navigation; reading the
|
// In-memory cache. The proxy fires on every page navigation; reading the
|
||||||
// registry JSON every time is fine but cheap to skip when nothing has
|
// registry JSON every time is fine but cheap to skip when nothing has
|
||||||
// changed. Five seconds is short enough to make plugin install/uninstall
|
// changed. Five seconds is short enough to make plugin install/uninstall
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
// Server-side admin plugin-approval store.
|
||||||
|
//
|
||||||
|
// Closes the "C4" audit finding: previously a plugin's `adminApproved` flag
|
||||||
|
// was client-only, so a malicious user could enable a plugin past the policy
|
||||||
|
// gate via DevTools. The server now tracks per-(pluginId, bundleHash) status
|
||||||
|
// and the `enablePlugin` flow consults it before letting a non-managed plugin
|
||||||
|
// run.
|
||||||
|
//
|
||||||
|
// Each entry has one of three states: 'pending' (user installed, waiting for
|
||||||
|
// admin), 'approved' (admin signed off), 'denied' (admin refused — kept so we
|
||||||
|
// don't keep asking).
|
||||||
|
|
||||||
|
import { readFile, writeFile, rename } from 'node:fs/promises';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { ensureConfigDir, getConfigPath, assertWritable } from './paths';
|
||||||
|
|
||||||
|
export type ApprovalStatus = 'pending' | 'approved' | 'denied';
|
||||||
|
|
||||||
|
export interface ApprovalEntry {
|
||||||
|
pluginId: string;
|
||||||
|
bundleHash: string;
|
||||||
|
status: ApprovalStatus;
|
||||||
|
/** Snapshot of the manifest at request time. */
|
||||||
|
manifest: {
|
||||||
|
name?: string;
|
||||||
|
version?: string;
|
||||||
|
author?: string;
|
||||||
|
description?: string;
|
||||||
|
permissions?: string[];
|
||||||
|
httpOrigins?: string[];
|
||||||
|
apiPostPaths?: string[];
|
||||||
|
};
|
||||||
|
requestedBy: string; // JMAP username who triggered the request
|
||||||
|
requestedAt: string; // ISO 8601
|
||||||
|
decidedBy?: string; // admin username (set on approve/deny)
|
||||||
|
decidedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApprovalsFile {
|
||||||
|
entries: ApprovalEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const APPROVALS_FILE = 'plugin-approvals.json';
|
||||||
|
const MAX_ENTRIES = 500; // hard cap so a misbehaving client can't grow the file unboundedly
|
||||||
|
|
||||||
|
let cached: ApprovalsFile | null = null;
|
||||||
|
let loadPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
async function loadFromDisk(): Promise<ApprovalsFile> {
|
||||||
|
await ensureConfigDir();
|
||||||
|
const path = getConfigPath(APPROVALS_FILE);
|
||||||
|
if (!existsSync(path)) return { entries: [] };
|
||||||
|
try {
|
||||||
|
const raw = await readFile(path, 'utf-8');
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!parsed || !Array.isArray(parsed.entries)) return { entries: [] };
|
||||||
|
return { entries: parsed.entries.filter(isWellFormed) };
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('[plugin-approvals] failed to read file', { error: err instanceof Error ? err.message : String(err) });
|
||||||
|
return { entries: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureLoaded(): Promise<void> {
|
||||||
|
if (cached !== null) return;
|
||||||
|
if (!loadPromise) {
|
||||||
|
loadPromise = (async () => { cached = await loadFromDisk(); })();
|
||||||
|
}
|
||||||
|
await loadPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushToDisk(): Promise<void> {
|
||||||
|
if (!cached) return;
|
||||||
|
await ensureConfigDir();
|
||||||
|
assertWritable('plugin-approvals.flushToDisk');
|
||||||
|
const path = getConfigPath(APPROVALS_FILE);
|
||||||
|
const tmp = `${path}.tmp`;
|
||||||
|
await writeFile(tmp, JSON.stringify(cached, null, 2), 'utf-8');
|
||||||
|
await rename(tmp, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWellFormed(value: unknown): value is ApprovalEntry {
|
||||||
|
if (!value || typeof value !== 'object') return false;
|
||||||
|
const v = value as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
typeof v.pluginId === 'string' &&
|
||||||
|
typeof v.bundleHash === 'string' &&
|
||||||
|
(v.status === 'pending' || v.status === 'approved' || v.status === 'denied') &&
|
||||||
|
typeof v.requestedBy === 'string' &&
|
||||||
|
typeof v.requestedAt === 'string' &&
|
||||||
|
typeof v.manifest === 'object' && v.manifest !== null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findEntry(file: ApprovalsFile, pluginId: string, bundleHash: string): ApprovalEntry | undefined {
|
||||||
|
return file.entries.find(e => e.pluginId === pluginId && e.bundleHash === bundleHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Public API ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function listApprovals(): Promise<ApprovalEntry[]> {
|
||||||
|
await ensureLoaded();
|
||||||
|
return [...cached!.entries];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getApprovalStatus(pluginId: string, bundleHash: string): Promise<{ status: ApprovalStatus | 'not-requested'; decidedAt?: string }> {
|
||||||
|
await ensureLoaded();
|
||||||
|
const entry = findEntry(cached!, pluginId, bundleHash);
|
||||||
|
if (!entry) return { status: 'not-requested' };
|
||||||
|
return { status: entry.status, decidedAt: entry.decidedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestApproval(
|
||||||
|
pluginId: string,
|
||||||
|
bundleHash: string,
|
||||||
|
manifest: ApprovalEntry['manifest'],
|
||||||
|
requestedBy: string,
|
||||||
|
): Promise<ApprovalEntry> {
|
||||||
|
if (!pluginId || !bundleHash) throw new Error('pluginId and bundleHash required');
|
||||||
|
await ensureLoaded();
|
||||||
|
const file = cached!;
|
||||||
|
const existing = findEntry(file, pluginId, bundleHash);
|
||||||
|
if (existing) return existing;
|
||||||
|
if (file.entries.length >= MAX_ENTRIES) {
|
||||||
|
// Drop the oldest pending entry so a new request can land. Approved/denied
|
||||||
|
// entries are preserved.
|
||||||
|
const oldestPendingIdx = file.entries.findIndex(e => e.status === 'pending');
|
||||||
|
if (oldestPendingIdx >= 0) file.entries.splice(oldestPendingIdx, 1);
|
||||||
|
else throw new Error('plugin-approvals file is full');
|
||||||
|
}
|
||||||
|
const entry: ApprovalEntry = {
|
||||||
|
pluginId,
|
||||||
|
bundleHash,
|
||||||
|
status: 'pending',
|
||||||
|
manifest,
|
||||||
|
requestedBy,
|
||||||
|
requestedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
file.entries.push(entry);
|
||||||
|
await flushToDisk();
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decideApproval(
|
||||||
|
pluginId: string,
|
||||||
|
bundleHash: string,
|
||||||
|
decision: 'approved' | 'denied',
|
||||||
|
decidedBy: string,
|
||||||
|
): Promise<ApprovalEntry> {
|
||||||
|
await ensureLoaded();
|
||||||
|
const file = cached!;
|
||||||
|
const entry = findEntry(file, pluginId, bundleHash);
|
||||||
|
if (!entry) throw new Error('approval entry not found');
|
||||||
|
entry.status = decision;
|
||||||
|
entry.decidedAt = new Date().toISOString();
|
||||||
|
entry.decidedBy = decidedBy;
|
||||||
|
await flushToDisk();
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeApproval(pluginId: string, bundleHash: string): Promise<void> {
|
||||||
|
await ensureLoaded();
|
||||||
|
const file = cached!;
|
||||||
|
const idx = file.entries.findIndex(e => e.pluginId === pluginId && e.bundleHash === bundleHash);
|
||||||
|
if (idx < 0) return;
|
||||||
|
file.entries.splice(idx, 1);
|
||||||
|
await flushToDisk();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Force a re-read on next access. Used in tests / after a manual file edit. */
|
||||||
|
export function invalidateApprovalsCache(): void {
|
||||||
|
cached = null;
|
||||||
|
loadPromise = null;
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { createHash } from 'node:crypto';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import type { ServerPlugin } from './plugin-registry';
|
import type { ServerPlugin } from './plugin-registry';
|
||||||
import { sanitizeFrameOrigins, sanitizeHttpOrigins } from './csp-frame-origins';
|
import { sanitizeFrameOrigins, sanitizeHttpOrigins, sanitizeApiPostPaths } from './csp-frame-origins';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dev-mode plugin loading.
|
* Dev-mode plugin loading.
|
||||||
@@ -177,6 +177,7 @@ async function loadDevPlugin(pluginDir: string): Promise<DevPluginEntry | null>
|
|||||||
|
|
||||||
const frameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
|
const frameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
|
||||||
const httpOrigins = sanitizeHttpOrigins(manifest.httpOrigins);
|
const httpOrigins = sanitizeHttpOrigins(manifest.httpOrigins);
|
||||||
|
const apiPostPaths = sanitizeApiPostPaths(manifest.apiPostPaths);
|
||||||
|
|
||||||
const plugin: ServerPlugin = {
|
const plugin: ServerPlugin = {
|
||||||
id,
|
id,
|
||||||
@@ -197,6 +198,7 @@ async function loadDevPlugin(pluginDir: string): Promise<DevPluginEntry | null>
|
|||||||
: {}),
|
: {}),
|
||||||
...(frameOrigins.length > 0 ? { frameOrigins } : {}),
|
...(frameOrigins.length > 0 ? { frameOrigins } : {}),
|
||||||
...(httpOrigins.length > 0 ? { httpOrigins } : {}),
|
...(httpOrigins.length > 0 ? { httpOrigins } : {}),
|
||||||
|
...(apiPostPaths.length > 0 ? { apiPostPaths } : {}),
|
||||||
installedAt,
|
installedAt,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
bundleHash,
|
bundleHash,
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ export interface ServerPlugin {
|
|||||||
* Same syntax as `frameOrigins`. Surfaced to clients via /api/plugins.
|
* Same syntax as `frameOrigins`. Surfaced to clients via /api/plugins.
|
||||||
*/
|
*/
|
||||||
httpOrigins?: string[];
|
httpOrigins?: string[];
|
||||||
|
/**
|
||||||
|
* Same-origin `/api/*` path allowlist for `api.http.post()`. See
|
||||||
|
* `InstalledPlugin.apiPostPaths` in `lib/plugin-types.ts`.
|
||||||
|
*/
|
||||||
|
apiPostPaths?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerTheme {
|
export interface ServerTheme {
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// Server-side Ed25519 signing for plugin bundles.
|
||||||
|
//
|
||||||
|
// Closes the "C2" audit finding: SHA-256 alone catches transport corruption
|
||||||
|
// but not a compromised server-side bundle store. With signing, even if an
|
||||||
|
// attacker swaps the bundle bytes in transit or at rest, the client refuses
|
||||||
|
// to load anything that doesn't verify against the host's public key.
|
||||||
|
//
|
||||||
|
// The keypair lives at `data/admin/plugin-signing.key` (PEM-encoded
|
||||||
|
// PKCS#8 private, mode 0600) and is generated lazily on first use. Operators
|
||||||
|
// who want to pin the key out-of-band can drop a pre-generated PEM at that
|
||||||
|
// path before first boot — the loader just reads what's there.
|
||||||
|
|
||||||
|
import { generateKeyPairSync, createPrivateKey, createPublicKey, sign as nodeSign, KeyObject } from 'node:crypto';
|
||||||
|
import { readFile, writeFile, chmod } from 'node:fs/promises';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { ensureConfigDir, getConfigPath, assertWritable } from './paths';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
const KEY_FILENAME = 'plugin-signing.key';
|
||||||
|
|
||||||
|
let cached: { privateKey: KeyObject; publicKey: KeyObject } | null = null;
|
||||||
|
let initPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
async function loadOrCreate(): Promise<{ privateKey: KeyObject; publicKey: KeyObject }> {
|
||||||
|
await ensureConfigDir();
|
||||||
|
const path = getConfigPath(KEY_FILENAME);
|
||||||
|
|
||||||
|
if (existsSync(path)) {
|
||||||
|
const pem = await readFile(path, 'utf-8');
|
||||||
|
const privateKey = createPrivateKey({ key: pem, format: 'pem' });
|
||||||
|
if (privateKey.asymmetricKeyType !== 'ed25519') {
|
||||||
|
throw new Error(`plugin-signing.key has wrong key type (${privateKey.asymmetricKeyType}); expected ed25519`);
|
||||||
|
}
|
||||||
|
const publicKey = createPublicKey(privateKey);
|
||||||
|
return { privateKey, publicKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
// First boot: generate and persist. Use sync APIs so a half-written file
|
||||||
|
// never lingers if the process dies between writes.
|
||||||
|
assertWritable('plugin-signing.generateKeypair');
|
||||||
|
const { privateKey, publicKey } = generateKeyPairSync('ed25519');
|
||||||
|
const pem = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string;
|
||||||
|
await writeFile(path, pem, { encoding: 'utf-8', mode: 0o600 });
|
||||||
|
// Ensure 0600 on filesystems that ignored mode on writeFile.
|
||||||
|
try { await chmod(path, 0o600); } catch { /* best effort */ }
|
||||||
|
logger.info('[plugin-signing] generated new Ed25519 keypair');
|
||||||
|
return { privateKey, publicKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureLoaded(): Promise<void> {
|
||||||
|
if (cached) return;
|
||||||
|
if (!initPromise) {
|
||||||
|
initPromise = (async () => {
|
||||||
|
try {
|
||||||
|
cached = await loadOrCreate();
|
||||||
|
} catch (err) {
|
||||||
|
initPromise = null;
|
||||||
|
logger.error('[plugin-signing] keypair load failed', { error: err instanceof Error ? err.message : String(err) });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
await initPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Public API ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Returns the public key as a raw 32-byte Uint8Array (Ed25519 standard form). */
|
||||||
|
export async function getPublicKeyRaw(): Promise<Uint8Array> {
|
||||||
|
await ensureLoaded();
|
||||||
|
// Export as SPKI DER and pull the last 32 bytes (the raw key after the
|
||||||
|
// 12-byte AlgorithmIdentifier prefix). Node has no built-in raw export
|
||||||
|
// for Ed25519, but the SPKI prefix is fixed for Ed25519 so the slice is
|
||||||
|
// safe.
|
||||||
|
const spki = cached!.publicKey.export({ type: 'spki', format: 'der' }) as Buffer;
|
||||||
|
if (spki.length < 32) throw new Error('SPKI export too short');
|
||||||
|
return new Uint8Array(spki.subarray(spki.length - 32));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Base64-encoded raw 32-byte public key (for embedding in HTTP responses). */
|
||||||
|
export async function getPublicKeyBase64(): Promise<string> {
|
||||||
|
const raw = await getPublicKeyRaw();
|
||||||
|
return Buffer.from(raw).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sign `bytes` and return a base64-encoded 64-byte Ed25519 signature. */
|
||||||
|
export async function signBytes(bytes: Uint8Array | string): Promise<string> {
|
||||||
|
await ensureLoaded();
|
||||||
|
const data = typeof bytes === 'string' ? Buffer.from(bytes, 'utf-8') : Buffer.from(bytes);
|
||||||
|
const sig = nodeSign(null, data, cached!.privateKey);
|
||||||
|
return sig.toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Force a re-read on next access. Used after operator rotates the key. */
|
||||||
|
export function invalidatePluginSigningCache(): void {
|
||||||
|
cached = null;
|
||||||
|
initPromise = null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// Client-side Ed25519 verification for plugin bundles.
|
||||||
|
//
|
||||||
|
// On boot the loader fetches the host's public key from
|
||||||
|
// `/api/plugin-signing-pubkey`. Each `/api/admin/plugins/[id]/bundle` response
|
||||||
|
// includes the signature as the `X-Bundle-Signature` header. Before evaluating
|
||||||
|
// a bundle the loader verifies the signature; mismatch refuses the load.
|
||||||
|
//
|
||||||
|
// User-installed plugins (uploaded via the file picker, no server hop) have
|
||||||
|
// no signature — verification is skipped for those, since the user is
|
||||||
|
// installing their own code. Verification kicks in for server-managed
|
||||||
|
// bundles only (the `managed: true` flag on `InstalledPlugin`).
|
||||||
|
|
||||||
|
let cachedPubKey: CryptoKey | null = null;
|
||||||
|
let pubKeyPromise: Promise<CryptoKey | null> | null = null;
|
||||||
|
|
||||||
|
async function importEd25519PublicKey(raw: Uint8Array): Promise<CryptoKey | null> {
|
||||||
|
if (typeof crypto === 'undefined' || !crypto.subtle) return null;
|
||||||
|
try {
|
||||||
|
// Browser Web Crypto supports Ed25519 via `name: 'Ed25519'` (no hash).
|
||||||
|
return await crypto.subtle.importKey('raw', raw.buffer.slice(0) as ArrayBuffer, { name: 'Ed25519' }, false, ['verify']);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[plugin-signing] Web Crypto Ed25519 import failed', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPublicKey(): Promise<CryptoKey | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/plugin-signing-pubkey', { credentials: 'same-origin' });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json() as { algorithm?: string; publicKey?: string };
|
||||||
|
if (data.algorithm !== 'ed25519' || typeof data.publicKey !== 'string') return null;
|
||||||
|
const raw = base64ToBytes(data.publicKey);
|
||||||
|
if (raw.length !== 32) return null;
|
||||||
|
return importEd25519PublicKey(raw);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[plugin-signing] could not fetch public key', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPluginSigningKey(): Promise<CryptoKey | null> {
|
||||||
|
if (cachedPubKey) return cachedPubKey;
|
||||||
|
if (!pubKeyPromise) {
|
||||||
|
pubKeyPromise = fetchPublicKey().then((k) => { cachedPubKey = k; return k; });
|
||||||
|
}
|
||||||
|
return pubKeyPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Force a refresh on next access (e.g. after key rotation). */
|
||||||
|
export function invalidatePluginSigningKeyCache(): void {
|
||||||
|
cachedPubKey = null;
|
||||||
|
pubKeyPromise = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a base64 Ed25519 signature against the bundle bytes. Returns false
|
||||||
|
* on any failure (missing key, invalid encoding, signature mismatch). Never
|
||||||
|
* throws.
|
||||||
|
*/
|
||||||
|
export async function verifySignature(code: string, signatureB64: string): Promise<boolean> {
|
||||||
|
if (!signatureB64) return false;
|
||||||
|
const key = await getPluginSigningKey();
|
||||||
|
if (!key) return false;
|
||||||
|
let signature: Uint8Array;
|
||||||
|
try {
|
||||||
|
signature = base64ToBytes(signatureB64);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (signature.length !== 64) return false;
|
||||||
|
const data = new TextEncoder().encode(code);
|
||||||
|
try {
|
||||||
|
return await crypto.subtle.verify(
|
||||||
|
{ name: 'Ed25519' },
|
||||||
|
key,
|
||||||
|
signature.buffer.slice(0) as ArrayBuffer,
|
||||||
|
data.buffer.slice(0) as ArrayBuffer,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64ToBytes(b64: string): Uint8Array {
|
||||||
|
const bin = atob(b64);
|
||||||
|
const bytes = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
@@ -100,7 +100,24 @@ function storageKeys(pluginId: string): string[] {
|
|||||||
|
|
||||||
// ─── http.post (same-origin /api/*) ───────────────────────────
|
// ─── http.post (same-origin /api/*) ───────────────────────────
|
||||||
|
|
||||||
async function doHttpPost(path: string, body: unknown): Promise<{ ok: boolean; status: number; data: unknown }> {
|
/**
|
||||||
|
* Returns true iff `path` is permitted by the plugin's `apiPostPaths`
|
||||||
|
* allowlist. Entries are either exact paths (must equal `path`) or prefixes
|
||||||
|
* that end with `/` (`path` must start with the entry).
|
||||||
|
*/
|
||||||
|
function isApiPostPathAllowed(path: string, allowlist: readonly string[]): boolean {
|
||||||
|
for (const entry of allowlist) {
|
||||||
|
if (typeof entry !== 'string' || !entry.startsWith('/api/')) continue;
|
||||||
|
if (entry.endsWith('/')) {
|
||||||
|
if (path === entry || path.startsWith(entry)) return true;
|
||||||
|
} else if (path === entry) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doHttpPost(plugin: InstalledPlugin, path: string, body: unknown): Promise<{ ok: boolean; status: number; data: unknown }> {
|
||||||
if (typeof path !== 'string' || !path.startsWith('/api/')) {
|
if (typeof path !== 'string' || !path.startsWith('/api/')) {
|
||||||
throw new Error('path must start with /api/');
|
throw new Error('path must start with /api/');
|
||||||
}
|
}
|
||||||
@@ -108,6 +125,15 @@ async function doHttpPost(path: string, body: unknown): Promise<{ ok: boolean; s
|
|||||||
if (url.origin !== window.location.origin) {
|
if (url.origin !== window.location.origin) {
|
||||||
throw new Error('path must resolve to the same origin');
|
throw new Error('path must resolve to the same origin');
|
||||||
}
|
}
|
||||||
|
// Per-plugin path allow-list. Comparison is on the pathname only (query
|
||||||
|
// strings don't widen the surface, so we ignore them here).
|
||||||
|
const allow = plugin.apiPostPaths ?? [];
|
||||||
|
if (allow.length === 0) {
|
||||||
|
throw new Error(`Plugin "${plugin.id}" has no apiPostPaths declared`);
|
||||||
|
}
|
||||||
|
if (!isApiPostPathAllowed(url.pathname, allow)) {
|
||||||
|
throw new Error(`Path ${url.pathname} not in plugin apiPostPaths allowlist`);
|
||||||
|
}
|
||||||
const { client } = useAuthStore.getState();
|
const { client } = useAuthStore.getState();
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
if (client) {
|
if (client) {
|
||||||
@@ -230,7 +256,7 @@ export async function dispatchApiCall(
|
|||||||
case 'toast.info': appToast.info(String(args[0] ?? '')); return undefined;
|
case 'toast.info': appToast.info(String(args[0] ?? '')); return undefined;
|
||||||
case 'toast.warning': appToast.warning(String(args[0] ?? '')); return undefined;
|
case 'toast.warning': appToast.warning(String(args[0] ?? '')); return undefined;
|
||||||
|
|
||||||
case 'http.post': return doHttpPost(args[0] as string, args[1]);
|
case 'http.post': return doHttpPost(plugin, args[0] as string, args[1]);
|
||||||
case 'http.fetch': return doHttpFetch(plugin, args[0] as string, args[1] as PluginFetchInit | undefined);
|
case 'http.fetch': return doHttpFetch(plugin, args[0] as string, args[1] as PluginFetchInit | undefined);
|
||||||
|
|
||||||
case 'admin.getConfig': return adminGet(plugin.id, args[0] as string);
|
case 'admin.getConfig': return adminGet(plugin.id, args[0] as string);
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export interface SlotOptions {
|
|||||||
export interface InitDoneInfo {
|
export interface InitDoneInfo {
|
||||||
hooks: string[];
|
hooks: string[];
|
||||||
slots: Array<{ name: SlotName; hasShouldShow: boolean; order: number }>;
|
slots: Array<{ name: SlotName; hasShouldShow: boolean; order: number }>;
|
||||||
|
shortcuts: Array<{ id: string; keys: string; label: string; category?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Sandbox instance ────────────────────────────────────────
|
// ─── Sandbox instance ────────────────────────────────────────
|
||||||
@@ -169,7 +170,7 @@ export class SandboxInstance {
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
case 'init-done':
|
case 'init-done':
|
||||||
this.resolveInit({ hooks: msg.hooks, slots: msg.slots });
|
this.resolveInit({ hooks: msg.hooks, slots: msg.slots, shortcuts: msg.shortcuts ?? [] });
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case 'init-error':
|
case 'init-error':
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { verifyBundle } from './bundle-integrity';
|
|||||||
import { createBackgroundInstance } from './host-bridge';
|
import { createBackgroundInstance } from './host-bridge';
|
||||||
import { register as registerActive, deregister as deregisterActive } from './registry';
|
import { register as registerActive, deregister as deregisterActive } from './registry';
|
||||||
import { cancelPluginDialogs } from './host-api';
|
import { cancelPluginDialogs } from './host-api';
|
||||||
|
import { registerShortcuts } from './shortcuts';
|
||||||
|
|
||||||
// ─── Hook-bus lookup (one flat map for name → bus) ────────────
|
// ─── Hook-bus lookup (one flat map for name → bus) ────────────
|
||||||
|
|
||||||
@@ -80,9 +81,11 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise<void
|
|||||||
const info = await background.initPromise;
|
const info = await background.initPromise;
|
||||||
|
|
||||||
// Wire hook proxies: every hookName the plugin registered gets a HookBus
|
// Wire hook proxies: every hookName the plugin registered gets a HookBus
|
||||||
// entry whose handler dispatches into the sandbox.
|
// entry whose handler dispatches into the sandbox. `shortcut:<id>` hooks
|
||||||
|
// are dispatched by the keyboard module separately and don't have a bus.
|
||||||
const hookDisposables: Disposable[] = [];
|
const hookDisposables: Disposable[] = [];
|
||||||
for (const hookName of info.hooks) {
|
for (const hookName of info.hooks) {
|
||||||
|
if (hookName.startsWith('shortcut:')) continue;
|
||||||
const bus = HOOK_BUSES[hookName];
|
const bus = HOOK_BUSES[hookName];
|
||||||
if (!bus) {
|
if (!bus) {
|
||||||
console.warn(`[plugin-sandbox] Plugin "${plugin.id}" registered unknown hook "${hookName}"`);
|
console.warn(`[plugin-sandbox] Plugin "${plugin.id}" registered unknown hook "${hookName}"`);
|
||||||
@@ -99,6 +102,10 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise<void
|
|||||||
hookDisposables.push(bus.register(plugin.id, proxy as (...a: unknown[]) => unknown));
|
hookDisposables.push(bus.register(plugin.id, proxy as (...a: unknown[]) => unknown));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Install plugin-declared keyboard shortcuts.
|
||||||
|
const shortcutDispose = registerShortcuts(background, info.shortcuts ?? []);
|
||||||
|
hookDisposables.push({ dispose: shortcutDispose });
|
||||||
|
|
||||||
registerActive({
|
registerActive({
|
||||||
plugin,
|
plugin,
|
||||||
code,
|
code,
|
||||||
|
|||||||
@@ -73,6 +73,12 @@ export interface InitDoneMsg {
|
|||||||
hooks: string[];
|
hooks: string[];
|
||||||
/** Slots the plugin claims. Used by the host to know when a slot is offered. */
|
/** Slots the plugin claims. Used by the host to know when a slot is offered. */
|
||||||
slots: Array<{ name: SlotName; hasShouldShow: boolean; order: number }>;
|
slots: Array<{ name: SlotName; hasShouldShow: boolean; order: number }>;
|
||||||
|
/**
|
||||||
|
* Keyboard shortcuts the plugin declares. The host installs a global
|
||||||
|
* keydown listener that dispatches to the `shortcut:<id>` hook when the
|
||||||
|
* combo matches. `keys` is a `+`-separated string like "Ctrl+Shift+L".
|
||||||
|
*/
|
||||||
|
shortcuts: Array<{ id: string; keys: string; label: string; category?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InitErrorMsg { type: 'init-error'; error: string; }
|
export interface InitErrorMsg { type: 'init-error'; error: string; }
|
||||||
|
|||||||
@@ -34,6 +34,16 @@ import type { SlotName } from '../plugin-types';
|
|||||||
interface PluginExports {
|
interface PluginExports {
|
||||||
slots?: Record<string, { component: React.ComponentType<Record<string, unknown>>; shouldShow?: (ctx: unknown) => boolean; order?: number }>;
|
slots?: Record<string, { component: React.ComponentType<Record<string, unknown>>; shouldShow?: (ctx: unknown) => boolean; order?: number }>;
|
||||||
hooks?: Record<string, (...args: unknown[]) => unknown>;
|
hooks?: Record<string, (...args: unknown[]) => unknown>;
|
||||||
|
/**
|
||||||
|
* Keyboard shortcut bindings. Each entry's `handler` is registered as a
|
||||||
|
* hook named `shortcut:<id>` so the host's keydown dispatcher can fire it.
|
||||||
|
*/
|
||||||
|
shortcuts?: Record<string, {
|
||||||
|
keys: string;
|
||||||
|
label: string;
|
||||||
|
category?: string;
|
||||||
|
handler: () => void | Promise<void>;
|
||||||
|
}>;
|
||||||
activate?: (api: unknown) => void | Promise<void> | { dispose: () => void };
|
activate?: (api: unknown) => void | Promise<void> | { dispose: () => void };
|
||||||
default?: unknown;
|
default?: unknown;
|
||||||
}
|
}
|
||||||
@@ -243,12 +253,28 @@ async function bootBackground(payload: BackgroundInit): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shortcuts: register each handler as a 'shortcut:<id>' hook so the host's
|
||||||
|
// global keydown dispatcher can invoke it.
|
||||||
|
const shortcutInfo: Array<{ id: string; keys: string; label: string; category?: string }> = [];
|
||||||
|
const shortcuts = exports.shortcuts ?? {};
|
||||||
|
for (const [id, def] of Object.entries(shortcuts)) {
|
||||||
|
if (!def || typeof def.handler !== 'function' || typeof def.keys !== 'string') continue;
|
||||||
|
hookHandlers[`shortcut:${id}`] = def.handler as (...args: unknown[]) => unknown;
|
||||||
|
hookNames.push(`shortcut:${id}`);
|
||||||
|
shortcutInfo.push({
|
||||||
|
id,
|
||||||
|
keys: def.keys,
|
||||||
|
label: typeof def.label === 'string' ? def.label : id,
|
||||||
|
category: typeof def.category === 'string' ? def.category : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Side effects.
|
// Side effects.
|
||||||
if (typeof exports.activate === 'function') {
|
if (typeof exports.activate === 'function') {
|
||||||
await Promise.resolve(exports.activate(api));
|
await Promise.resolve(exports.activate(api));
|
||||||
}
|
}
|
||||||
|
|
||||||
sendToHost({ type: 'init-done', hooks: hookNames, slots: slotInfo });
|
sendToHost({ type: 'init-done', hooks: hookNames, slots: slotInfo, shortcuts: shortcutInfo });
|
||||||
}
|
}
|
||||||
|
|
||||||
function bootSlot(payload: SlotInit): void {
|
function bootSlot(payload: SlotInit): void {
|
||||||
@@ -305,7 +331,7 @@ function bootSlot(payload: SlotInit): void {
|
|||||||
|
|
||||||
const reactRoot = ReactDOM.createRoot(rootEl);
|
const reactRoot = ReactDOM.createRoot(rootEl);
|
||||||
reactRoot.render(React.createElement(SlotShell));
|
reactRoot.render(React.createElement(SlotShell));
|
||||||
sendToHost({ type: 'init-done', hooks: [], slots: [] });
|
sendToHost({ type: 'init-done', hooks: [], slots: [], shortcuts: [] });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Populated by bootSlot — receives `props-update` messages.
|
// Populated by bootSlot — receives `props-update` messages.
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// Plugin shortcut dispatcher.
|
||||||
|
//
|
||||||
|
// Each enabled plugin declares zero-or-more keyboard shortcuts via its
|
||||||
|
// `shortcuts` export. On init the host registers each binding here. A single
|
||||||
|
// window keydown listener matches keys against the active bindings and
|
||||||
|
// dispatches via `instance.invokeHook('shortcut:<id>', [])`.
|
||||||
|
//
|
||||||
|
// The listener ignores events when an editable element has focus, matching
|
||||||
|
// the convention in `use-keyboard-shortcuts.ts`.
|
||||||
|
|
||||||
|
import type { SandboxInstance } from './host-bridge';
|
||||||
|
|
||||||
|
interface Binding {
|
||||||
|
pluginId: string;
|
||||||
|
shortcutId: string;
|
||||||
|
keys: string; // "Ctrl+Shift+L"
|
||||||
|
label: string;
|
||||||
|
category?: string;
|
||||||
|
invoke: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NormalisedCombo {
|
||||||
|
ctrl: boolean;
|
||||||
|
shift: boolean;
|
||||||
|
alt: boolean;
|
||||||
|
meta: boolean;
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bindings = new Map<string, Binding>(); // key: `${pluginId}:${shortcutId}`
|
||||||
|
let listenerInstalled = false;
|
||||||
|
|
||||||
|
function normaliseCombo(combo: string): NormalisedCombo | null {
|
||||||
|
if (typeof combo !== 'string') return null;
|
||||||
|
const parts = combo.split('+').map(p => p.trim()).filter(Boolean);
|
||||||
|
if (parts.length === 0) return null;
|
||||||
|
let ctrl = false, shift = false, alt = false, meta = false;
|
||||||
|
let key = '';
|
||||||
|
for (const p of parts) {
|
||||||
|
const lower = p.toLowerCase();
|
||||||
|
if (lower === 'ctrl' || lower === 'control') ctrl = true;
|
||||||
|
else if (lower === 'shift') shift = true;
|
||||||
|
else if (lower === 'alt' || lower === 'option') alt = true;
|
||||||
|
else if (lower === 'meta' || lower === 'cmd' || lower === 'command') meta = true;
|
||||||
|
else key = lower;
|
||||||
|
}
|
||||||
|
if (!key) return null;
|
||||||
|
return { ctrl, shift, alt, meta, key };
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventMatches(ev: KeyboardEvent, combo: NormalisedCombo): boolean {
|
||||||
|
if (combo.ctrl !== (ev.ctrlKey || ev.metaKey ? ev.ctrlKey : false)) {
|
||||||
|
// Treat Ctrl and Cmd as equivalent: a binding declaring Ctrl matches a
|
||||||
|
// Cmd press on macOS.
|
||||||
|
if (combo.ctrl) {
|
||||||
|
if (!(ev.ctrlKey || ev.metaKey)) return false;
|
||||||
|
} else if (ev.ctrlKey) return false;
|
||||||
|
}
|
||||||
|
if (combo.shift !== ev.shiftKey) return false;
|
||||||
|
if (combo.alt !== ev.altKey) return false;
|
||||||
|
if (!combo.ctrl && combo.meta !== ev.metaKey) return false;
|
||||||
|
return ev.key.toLowerCase() === combo.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEditableTarget(target: EventTarget | null): boolean {
|
||||||
|
if (!(target instanceof HTMLElement)) return false;
|
||||||
|
const tag = target.tagName.toLowerCase();
|
||||||
|
if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
|
||||||
|
if (target.isContentEditable) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(ev: KeyboardEvent): void {
|
||||||
|
if (isEditableTarget(ev.target)) return;
|
||||||
|
if (bindings.size === 0) return;
|
||||||
|
for (const binding of bindings.values()) {
|
||||||
|
const combo = normaliseCombo(binding.keys);
|
||||||
|
if (!combo) continue;
|
||||||
|
if (eventMatches(ev, combo)) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
void binding.invoke();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureListener(): void {
|
||||||
|
if (listenerInstalled || typeof window === 'undefined') return;
|
||||||
|
listenerInstalled = true;
|
||||||
|
window.addEventListener('keydown', onKeyDown, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerShortcuts(
|
||||||
|
instance: SandboxInstance,
|
||||||
|
shortcuts: Array<{ id: string; keys: string; label: string; category?: string }>,
|
||||||
|
): () => void {
|
||||||
|
ensureListener();
|
||||||
|
const keys: string[] = [];
|
||||||
|
for (const sc of shortcuts) {
|
||||||
|
const key = `${instance.pluginId}:${sc.id}`;
|
||||||
|
bindings.set(key, {
|
||||||
|
pluginId: instance.pluginId,
|
||||||
|
shortcutId: sc.id,
|
||||||
|
keys: sc.keys,
|
||||||
|
label: sc.label,
|
||||||
|
category: sc.category,
|
||||||
|
invoke: async () => {
|
||||||
|
try {
|
||||||
|
await instance.invokeHook(`shortcut:${sc.id}`, []);
|
||||||
|
} catch {
|
||||||
|
/* hook tracker already logs */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
keys.push(key);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
for (const k of keys) bindings.delete(k);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Snapshot of currently active shortcuts. Used by the help modal. */
|
||||||
|
export function listShortcuts(): Array<{ pluginId: string; id: string; keys: string; label: string; category?: string }> {
|
||||||
|
return [...bindings.values()].map(b => ({
|
||||||
|
pluginId: b.pluginId,
|
||||||
|
id: b.shortcutId,
|
||||||
|
keys: b.keys,
|
||||||
|
label: b.label,
|
||||||
|
category: b.category,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -127,6 +127,15 @@ export interface PluginManifest {
|
|||||||
* The remote host must serve CORS headers permitting the webmail origin.
|
* The remote host must serve CORS headers permitting the webmail origin.
|
||||||
*/
|
*/
|
||||||
httpOrigins?: string[];
|
httpOrigins?: string[];
|
||||||
|
/**
|
||||||
|
* Same-origin `/api/*` paths this plugin may target via `api.http.post()`.
|
||||||
|
* Each entry is a path prefix; a call to `api.http.post('/api/X', ...)` is
|
||||||
|
* accepted iff `'/api/X'` exactly equals an entry OR an entry ends in
|
||||||
|
* `/` and `'/api/X'` starts with it. With no entry (or an empty array),
|
||||||
|
* the plugin may not call `api.http.post` even with the `http:post`
|
||||||
|
* permission. Validated at install time.
|
||||||
|
*/
|
||||||
|
apiPostPaths?: string[];
|
||||||
|
|
||||||
// ─── Marketplace media (NOT shipped in the runtime zip) ──────
|
// ─── Marketplace media (NOT shipped in the runtime zip) ──────
|
||||||
/**
|
/**
|
||||||
@@ -224,6 +233,11 @@ export interface InstalledPlugin {
|
|||||||
* `api.http.fetch()`. Carried over from the manifest at install time.
|
* `api.http.fetch()`. Carried over from the manifest at install time.
|
||||||
*/
|
*/
|
||||||
httpOrigins?: string[];
|
httpOrigins?: string[];
|
||||||
|
/**
|
||||||
|
* Validated allowlist of same-origin `/api/*` paths this plugin may target
|
||||||
|
* via `api.http.post()`. Carried over from the manifest at install time.
|
||||||
|
*/
|
||||||
|
apiPostPaths?: string[];
|
||||||
/**
|
/**
|
||||||
* Permissions the user has explicitly granted. Populated by the in-app
|
* Permissions the user has explicitly granted. Populated by the in-app
|
||||||
* consent dialog the first time the plugin is enabled. The host API gate
|
* consent dialog the first time the plugin is enabled. The host API gate
|
||||||
|
|||||||
+101
-4
@@ -9,6 +9,8 @@ import { extractPlugin } from '@/lib/plugin-validator';
|
|||||||
import { loadPlugin, deactivatePlugin, setPluginStoreAccessor, setupAutoDisable } from '@/lib/plugin-loader';
|
import { loadPlugin, deactivatePlugin, setPluginStoreAccessor, setupAutoDisable } from '@/lib/plugin-loader';
|
||||||
import { removeAllPluginHooks } from '@/lib/plugin-hooks';
|
import { removeAllPluginHooks } from '@/lib/plugin-hooks';
|
||||||
import { requestConsent } from '@/lib/plugin-sandbox/consent';
|
import { requestConsent } from '@/lib/plugin-sandbox/consent';
|
||||||
|
import { sha256Hex } from '@/lib/plugin-sandbox/bundle-integrity';
|
||||||
|
import { verifySignature } from '@/lib/plugin-sandbox/bundle-signing';
|
||||||
import { usePolicyStore } from '@/stores/policy-store';
|
import { usePolicyStore } from '@/stores/policy-store';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
import { IMPLICIT_PERMISSIONS } from '@/lib/plugin-types';
|
import { IMPLICIT_PERMISSIONS } from '@/lib/plugin-types';
|
||||||
@@ -60,6 +62,10 @@ export const usePluginStore = create<PluginStoreState>()(
|
|||||||
deactivatePlugin(manifest.id);
|
deactivatePlugin(manifest.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compute bundleHash so the admin-approval gate can pin to this
|
||||||
|
// specific bundle (server-side state keys on (id, hash) pairs).
|
||||||
|
const bundleHash = await sha256Hex(code).catch(() => undefined);
|
||||||
|
|
||||||
const plugin: InstalledPlugin = {
|
const plugin: InstalledPlugin = {
|
||||||
id: manifest.id,
|
id: manifest.id,
|
||||||
name: manifest.name,
|
name: manifest.name,
|
||||||
@@ -76,9 +82,13 @@ export const usePluginStore = create<PluginStoreState>()(
|
|||||||
adminApproved: false, // Requires admin approval before it can be enabled
|
adminApproved: false, // Requires admin approval before it can be enabled
|
||||||
settings: existing?.settings ?? {},
|
settings: existing?.settings ?? {},
|
||||||
settingsSchema: manifest.settingsSchema,
|
settingsSchema: manifest.settingsSchema,
|
||||||
|
...(bundleHash ? { bundleHash } : {}),
|
||||||
...(manifest.httpOrigins && manifest.httpOrigins.length > 0
|
...(manifest.httpOrigins && manifest.httpOrigins.length > 0
|
||||||
? { httpOrigins: manifest.httpOrigins }
|
? { httpOrigins: manifest.httpOrigins }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(manifest.apiPostPaths && manifest.apiPostPaths.length > 0
|
||||||
|
? { apiPostPaths: manifest.apiPostPaths }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save code to IndexedDB
|
// Save code to IndexedDB
|
||||||
@@ -127,10 +137,37 @@ export const usePluginStore = create<PluginStoreState>()(
|
|||||||
const plugin = plugins.find(p => p.id === id);
|
const plugin = plugins.find(p => p.id === id);
|
||||||
if (!plugin) return;
|
if (!plugin) return;
|
||||||
|
|
||||||
// Block enabling if plugin requires admin approval and hasn't been approved
|
// Admin approval gate. Managed (admin-pushed) plugins are pre-
|
||||||
|
// approved. For user-installed plugins the server-side state is
|
||||||
|
// authoritative: the client-only `isPluginApproved` flag is kept as
|
||||||
|
// a fast-path hint but the server result wins.
|
||||||
const requireApproval = usePolicyStore.getState().isFeatureEnabled('requirePluginApproval');
|
const requireApproval = usePolicyStore.getState().isFeatureEnabled('requirePluginApproval');
|
||||||
const isApproved = plugin.adminApproved || plugin.managed || usePolicyStore.getState().isPluginApproved(id);
|
const policyApproved = plugin.adminApproved || plugin.managed || usePolicyStore.getState().isPluginApproved(id);
|
||||||
if (requireApproval && !isApproved) return;
|
if (requireApproval && !policyApproved && plugin.bundleHash) {
|
||||||
|
const status = await checkServerApproval(plugin.id, plugin.bundleHash).catch(() => null);
|
||||||
|
if (status?.status === 'approved') {
|
||||||
|
// Approval available; proceed.
|
||||||
|
} else if (status?.status === 'denied') {
|
||||||
|
set(state => ({
|
||||||
|
plugins: state.plugins.map(p =>
|
||||||
|
p.id === id ? { ...p, status: 'error' as PluginStatus, error: 'Plugin denied by administrator' } : p
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
// 'pending' or 'not-requested' — submit a request and refuse to enable.
|
||||||
|
await submitApprovalRequest(plugin).catch(() => { /* best effort */ });
|
||||||
|
set(state => ({
|
||||||
|
plugins: state.plugins.map(p =>
|
||||||
|
p.id === id ? { ...p, status: 'error' as PluginStatus, error: 'Awaiting administrator approval' } : p
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (requireApproval && !policyApproved) {
|
||||||
|
// No bundleHash means we can't pin the approval — refuse.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Per-user consent gate: prompt for any permission the user has not
|
// Per-user consent gate: prompt for any permission the user has not
|
||||||
// explicitly approved yet. Managed plugins (admin-pushed) skip this —
|
// explicitly approved yet. Managed plugins (admin-pushed) skip this —
|
||||||
@@ -283,6 +320,8 @@ interface ServerPluginInfo {
|
|||||||
dev?: boolean;
|
dev?: boolean;
|
||||||
/** Allowlist of origins this plugin may target via api.http.fetch(). */
|
/** Allowlist of origins this plugin may target via api.http.fetch(). */
|
||||||
httpOrigins?: string[];
|
httpOrigins?: string[];
|
||||||
|
/** Allowlist of same-origin /api/* paths this plugin may target via api.http.post(). */
|
||||||
|
apiPostPaths?: string[];
|
||||||
/** Per-user settings schema, captured from the manifest server-side. */
|
/** Per-user settings schema, captured from the manifest server-side. */
|
||||||
settingsSchema?: InstalledPlugin['settingsSchema'];
|
settingsSchema?: InstalledPlugin['settingsSchema'];
|
||||||
}
|
}
|
||||||
@@ -389,6 +428,9 @@ async function syncServerPlugins(
|
|||||||
...(sp.httpOrigins && sp.httpOrigins.length > 0
|
...(sp.httpOrigins && sp.httpOrigins.length > 0
|
||||||
? { httpOrigins: sp.httpOrigins }
|
? { httpOrigins: sp.httpOrigins }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(sp.apiPostPaths && sp.apiPostPaths.length > 0
|
||||||
|
? { apiPostPaths: sp.apiPostPaths }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
set(state => {
|
set(state => {
|
||||||
@@ -425,6 +467,7 @@ async function syncServerPlugins(
|
|||||||
forceEnabled: sp.forceEnabled,
|
forceEnabled: sp.forceEnabled,
|
||||||
bundleHash: sp.bundleHash,
|
bundleHash: sp.bundleHash,
|
||||||
httpOrigins: sp.httpOrigins,
|
httpOrigins: sp.httpOrigins,
|
||||||
|
apiPostPaths: sp.apiPostPaths,
|
||||||
settingsSchema: sp.settingsSchema,
|
settingsSchema: sp.settingsSchema,
|
||||||
}
|
}
|
||||||
: p
|
: p
|
||||||
@@ -498,9 +541,63 @@ async function downloadPluginBundle(pluginId: string, bundleHash?: string): Prom
|
|||||||
const suffix = bundleHash ? `?v=${encodeURIComponent(bundleHash)}` : '';
|
const suffix = bundleHash ? `?v=${encodeURIComponent(bundleHash)}` : '';
|
||||||
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/bundle${suffix}`);
|
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/bundle${suffix}`);
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
return await res.text();
|
const code = await res.text();
|
||||||
|
// Ed25519 signature verification. Present on every server-managed bundle
|
||||||
|
// since the signing module is server-side; refuse to persist a bundle
|
||||||
|
// that fails verification. If the header is missing (older server / dev
|
||||||
|
// build with signing disabled) we log and allow — the SHA-256 hash check
|
||||||
|
// at load time still catches transport corruption.
|
||||||
|
const sig = res.headers.get('X-Bundle-Signature');
|
||||||
|
if (sig) {
|
||||||
|
const ok = await verifySignature(code, sig);
|
||||||
|
if (!ok) {
|
||||||
|
console.error(`[plugin-store] Refusing bundle for "${pluginId}": signature verification failed`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(`[plugin-store] Bundle for "${pluginId}" has no Ed25519 signature; loading without it`);
|
||||||
|
}
|
||||||
|
return code;
|
||||||
} catch {
|
} catch {
|
||||||
console.warn(`[plugin-store] Failed to download bundle for plugin "${pluginId}"`);
|
console.warn(`[plugin-store] Failed to download bundle for plugin "${pluginId}"`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Server-side admin-approval helpers ───────────────────────
|
||||||
|
|
||||||
|
async function checkServerApproval(pluginId: string, bundleHash: string): Promise<{ status: 'pending' | 'approved' | 'denied' | 'not-requested' } | null> {
|
||||||
|
try {
|
||||||
|
const url = `/api/plugin-approval-status?pluginId=${encodeURIComponent(pluginId)}&bundleHash=${encodeURIComponent(bundleHash)}`;
|
||||||
|
const res = await apiFetch(url);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return await res.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitApprovalRequest(plugin: InstalledPlugin): Promise<void> {
|
||||||
|
if (!plugin.bundleHash) return;
|
||||||
|
try {
|
||||||
|
await apiFetch('/api/plugin-approval-status', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
pluginId: plugin.id,
|
||||||
|
bundleHash: plugin.bundleHash,
|
||||||
|
manifest: {
|
||||||
|
name: plugin.name,
|
||||||
|
version: plugin.version,
|
||||||
|
author: plugin.author,
|
||||||
|
description: plugin.description,
|
||||||
|
permissions: plugin.permissions,
|
||||||
|
httpOrigins: plugin.httpOrigins,
|
||||||
|
apiPostPaths: plugin.apiPostPaths,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* best effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user