mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 08:12:43 +00:00
fix: sign plugin bundles when they are installed and require the signature for privileged plugins
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
|
||||
import { getPluginBundle, getPlugin, getPluginSignature } from '@/lib/admin/plugin-registry';
|
||||
import { getDevPlugin, readDevBundle } from '@/lib/admin/plugin-dev';
|
||||
import { signBytes } from '@/lib/admin/plugin-signing';
|
||||
|
||||
@@ -65,7 +65,7 @@ export async function GET(
|
||||
};
|
||||
if (etag) headers['ETag'] = etag;
|
||||
|
||||
const signature = await safeSign(code);
|
||||
const signature = await getPluginSignature(id, code);
|
||||
if (signature) headers['X-Bundle-Signature'] = signature;
|
||||
|
||||
if (etag && request.headers.get('if-none-match') === etag) {
|
||||
|
||||
@@ -59,6 +59,14 @@ describe('downloadManagedBundle', () => {
|
||||
expect(console.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses an unsigned bundle for a plugin that must be signed', async () => {
|
||||
// Privileged plugins run with the app's own rights; without the
|
||||
// install-time signature anyone who can write the bundle file decides
|
||||
// what they run.
|
||||
mocks.apiFetch.mockResolvedValue(response('code'));
|
||||
await expect(downloadManagedBundle('my-plugin', 'abc', { requireSignature: true })).rejects.toThrow(/not signed/);
|
||||
});
|
||||
|
||||
it('reports an HTTP failure with the status', async () => {
|
||||
mocks.apiFetch.mockResolvedValue(response('', { ok: false, status: 404 }));
|
||||
await expect(downloadManagedBundle('my-plugin')).rejects.toThrow(/HTTP 404/);
|
||||
|
||||
@@ -104,7 +104,7 @@ describe('loadSandboxedPlugin bundle sourcing', () => {
|
||||
mocks.getCode.mockResolvedValue(null);
|
||||
mocks.downloadManagedBundle.mockResolvedValue(CODE);
|
||||
await loadSandboxedPlugin(plugin());
|
||||
expect(mocks.downloadManagedBundle).toHaveBeenCalledWith('managed-plugin', HASH);
|
||||
expect(mocks.downloadManagedBundle).toHaveBeenCalledWith('managed-plugin', HASH, { requireSignature: false });
|
||||
expect(mocks.saveCode).toHaveBeenCalledWith('managed-plugin', CODE);
|
||||
expect(lastStatus()).toMatchObject({ status: 'running' });
|
||||
expect(getActive('managed-plugin')?.code).toBe(CODE);
|
||||
@@ -141,7 +141,7 @@ describe('loadSandboxedPlugin bundle sourcing', () => {
|
||||
it('accepts a managed bundle without a recorded hash (older records)', async () => {
|
||||
mocks.downloadManagedBundle.mockResolvedValue(CODE);
|
||||
await loadSandboxedPlugin(plugin({ bundleHash: undefined }));
|
||||
expect(mocks.downloadManagedBundle).toHaveBeenCalledWith('managed-plugin', undefined);
|
||||
expect(mocks.downloadManagedBundle).toHaveBeenCalledWith('managed-plugin', undefined, { requireSignature: false });
|
||||
expect(lastStatus()).toMatchObject({ status: 'running' });
|
||||
});
|
||||
|
||||
|
||||
@@ -171,7 +171,7 @@ describe('usePluginStore', () => {
|
||||
it('installs a new server plugin and caches its bundle', async () => {
|
||||
await usePluginStore.getState().initializePlugins();
|
||||
|
||||
expect(mocks.downloadManagedBundle).toHaveBeenCalledWith('managed-plugin', 'hash-v1');
|
||||
expect(mocks.downloadManagedBundle).toHaveBeenCalledWith('managed-plugin', 'hash-v1', { requireSignature: false });
|
||||
expect(pluginStorage.saveCode).toHaveBeenCalledWith('managed-plugin', 'bundle-code');
|
||||
const p = usePluginStore.getState().plugins.find(x => x.id === 'managed-plugin');
|
||||
expect(p).toMatchObject({ managed: true, enabled: true, bundleHash: 'hash-v1' });
|
||||
@@ -183,7 +183,7 @@ describe('usePluginStore', () => {
|
||||
|
||||
await usePluginStore.getState().initializePlugins();
|
||||
|
||||
expect(mocks.downloadManagedBundle).toHaveBeenCalledWith('managed-plugin', 'hash-v1');
|
||||
expect(mocks.downloadManagedBundle).toHaveBeenCalledWith('managed-plugin', 'hash-v1', { requireSignature: false });
|
||||
expect(pluginStorage.saveCode).toHaveBeenCalledWith('managed-plugin', 'bundle-code');
|
||||
expect(usePluginStore.getState().plugins).toHaveLength(1);
|
||||
});
|
||||
@@ -204,7 +204,7 @@ describe('usePluginStore', () => {
|
||||
|
||||
await usePluginStore.getState().initializePlugins();
|
||||
|
||||
expect(mocks.downloadManagedBundle).toHaveBeenCalledWith('managed-plugin', 'hash-v1');
|
||||
expect(mocks.downloadManagedBundle).toHaveBeenCalledWith('managed-plugin', 'hash-v1', { requireSignature: false });
|
||||
expect(usePluginStore.getState().plugins[0].bundleHash).toBe('hash-v1');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPublicKey, verify } from 'node:crypto';
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
let dir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(path.join(tmpdir(), 'bw-plugin-registry-'));
|
||||
vi.stubEnv('ADMIN_CONFIG_DIR', dir);
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function verifies(code: string, signature: string): Promise<boolean> {
|
||||
const pem = await readFile(path.join(dir, 'plugin-signing.key'), 'utf-8');
|
||||
const publicKey = createPublicKey(pem);
|
||||
return verify(null, Buffer.from(code, 'utf-8'), publicKey, Buffer.from(signature, 'base64'));
|
||||
}
|
||||
|
||||
const PLUGIN = {
|
||||
id: 'signed-demo', name: 'S', version: '1.0.0', author: 'a', description: '', type: 'hook',
|
||||
permissions: [], entrypoint: 'index.js', enabled: true, installedAt: 'x', updatedAt: 'x',
|
||||
};
|
||||
|
||||
describe('plugin bundle signatures', () => {
|
||||
it('signs the bundle when it is installed', async () => {
|
||||
const registry = await import('../plugin-registry');
|
||||
await registry.savePlugin(PLUGIN, 'module.exports = {}');
|
||||
|
||||
const signature = await registry.getPluginSignature('signed-demo', 'module.exports = {}');
|
||||
|
||||
expect(signature).toBeTruthy();
|
||||
expect(await verifies('module.exports = {}', signature!)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the install-time signature when the file on disk changes', async () => {
|
||||
// Signing at serve time signed whatever was on disk, so a swapped
|
||||
// bundle still came with a valid signature.
|
||||
const registry = await import('../plugin-registry');
|
||||
await registry.savePlugin(PLUGIN, 'module.exports = {}');
|
||||
await writeFile(path.join(dir, 'plugins', 'signed-demo.js'), 'stealEverything()', 'utf-8');
|
||||
|
||||
const swapped = (await registry.getPluginBundle('signed-demo'))!;
|
||||
const signature = await registry.getPluginSignature('signed-demo', swapped);
|
||||
|
||||
expect(await verifies(swapped, signature!)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -205,6 +205,10 @@ export async function savePlugin(
|
||||
// Save code bundle
|
||||
const bundlePath = path.join(dir, `${plugin.id}.js`);
|
||||
await writeFile(bundlePath, code, 'utf-8');
|
||||
// Sign now and keep the signature with the bundle. Signing at serve time
|
||||
// would sign whatever is on disk, so a bundle file swapped after install
|
||||
// would still verify.
|
||||
await storePluginSignature(plugin.id, code);
|
||||
|
||||
// Stamp content hash + updatedAt so clients can detect re-uploads even
|
||||
// when the manifest version hasn't changed. Preserve the original
|
||||
@@ -249,13 +253,43 @@ export async function deletePlugin(id: string): Promise<boolean> {
|
||||
registry.plugins.splice(idx, 1);
|
||||
await writeJsonFile(pluginRegistryPath(), registry);
|
||||
|
||||
// Remove bundle file
|
||||
const bundlePath = path.join(getPluginsDir(), `${id}.js`);
|
||||
try { await unlink(bundlePath); } catch { /* ok if missing */ }
|
||||
// Remove bundle and signature files
|
||||
for (const file of [`${id}.js`, `${id}.sig`]) {
|
||||
try { await unlink(path.join(getPluginsDir(), file)); } catch { /* ok if missing */ }
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function storePluginSignature(id: string, code: string): Promise<string | null> {
|
||||
const sigPath = path.join(getPluginsDir(), `${id}.sig`);
|
||||
try {
|
||||
const { signBytes } = await import('./plugin-signing');
|
||||
const signature = await signBytes(code);
|
||||
await writeFile(sigPath, signature, 'utf-8');
|
||||
return signature;
|
||||
} catch (err) {
|
||||
// Without a key the bundle still installs; it just can't run privileged.
|
||||
try { await unlink(sigPath); } catch { /* none */ }
|
||||
logger.warn('[plugin-registry] could not sign plugin bundle', { id, error: err instanceof Error ? err.message : String(err) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The signature made when the bundle was installed. Bundles installed before
|
||||
* signatures were stored get one on first request (trust on first use), so
|
||||
* a later change to the file on disk stops verifying from then on.
|
||||
*/
|
||||
export async function getPluginSignature(id: string, code: string): Promise<string | null> {
|
||||
if (!SAFE_ID_RE.test(id)) return null;
|
||||
try {
|
||||
return (await readFile(path.join(getPluginsDir(), `${id}.sig`), 'utf-8')).trim() || null;
|
||||
} catch {
|
||||
return storePluginSignature(id, code);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPluginBundle(id: string): Promise<string | null> {
|
||||
const bundlePath = path.join(getPluginsDir(), `${id}.js`);
|
||||
try {
|
||||
|
||||
@@ -28,7 +28,11 @@ export function bundleUrl(pluginId: string, bundleHash?: string | null): string
|
||||
* load-time error shows up on the plugin card instead of the plugin silently
|
||||
* vanishing from the list.
|
||||
*/
|
||||
export async function downloadManagedBundle(pluginId: string, bundleHash?: string | null): Promise<string> {
|
||||
export async function downloadManagedBundle(
|
||||
pluginId: string,
|
||||
bundleHash?: string | null,
|
||||
opts: { requireSignature?: boolean } = {},
|
||||
): Promise<string> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await apiFetch(bundleUrl(pluginId, bundleHash));
|
||||
@@ -55,6 +59,10 @@ export async function downloadManagedBundle(pluginId: string, bundleHash?: strin
|
||||
if (!ok) {
|
||||
throw new Error(`Refusing the bundle for plugin "${pluginId}": signature verification failed`);
|
||||
}
|
||||
} else if (opts.requireSignature) {
|
||||
// A privileged plugin runs with full access to the app; it only loads
|
||||
// with a signature made when the admin installed it.
|
||||
throw new Error(`Refusing the bundle for plugin "${pluginId}": it is not signed`);
|
||||
} else {
|
||||
console.warn(`[plugin-bundle] Bundle for "${pluginId}" has no Ed25519 signature; loading without it`);
|
||||
}
|
||||
|
||||
@@ -100,7 +100,9 @@ async function getBundleCode(plugin: InstalledPlugin): Promise<string> {
|
||||
throw new Error(`No bundle in storage for plugin "${plugin.id}". Reinstall to populate.`);
|
||||
}
|
||||
|
||||
const code = await downloadManagedBundle(plugin.id, plugin.bundleHash);
|
||||
const code = await downloadManagedBundle(plugin.id, plugin.bundleHash, {
|
||||
requireSignature: plugin.tier === 'privileged',
|
||||
});
|
||||
await verifyBundle(code, plugin.bundleHash);
|
||||
try {
|
||||
await pluginStorage.saveCode(plugin.id, code);
|
||||
|
||||
@@ -23,14 +23,10 @@ export type TierResolution =
|
||||
*
|
||||
* Privileged tier gates (ALL required):
|
||||
* 1. Manifest declares the umbrella high-risk permission `crypto:full`.
|
||||
* 2. Signed bundle: only bundles delivered through the admin/server channel
|
||||
* are Ed25519-signed (verified at download time — see `verifySignature`
|
||||
* usage in the plugin store). Self-uploaded bundles are unsigned and can
|
||||
* therefore never reach the privileged tier. `managed` is the signal that
|
||||
* the bundle came through that signed channel.
|
||||
* 3. Admin approval pins operator trust in this specific bundle.
|
||||
* 4. Explicit high-risk consent for `crypto:full` (granted via the consent
|
||||
* dialog at enable time; admin-managed plugins are pre-approved).
|
||||
* 2. An admin installed it (`managed`). Admin installs are the operator's
|
||||
* decision, so there is no separate approval or per-user consent step.
|
||||
* 3. Its bundle verifies against the signature made at install time; the
|
||||
* download refuses an unsigned or mis-signed privileged bundle.
|
||||
*/
|
||||
export function resolvePluginTier(plugin: InstalledPlugin): TierResolution {
|
||||
if (plugin.tier !== 'privileged') {
|
||||
@@ -42,23 +38,14 @@ export function resolvePluginTier(plugin: InstalledPlugin): TierResolution {
|
||||
return { tier: null, error: 'Privileged tier requires the "crypto:full" permission' };
|
||||
}
|
||||
|
||||
// 2 + 3. Trust root: signed (managed) bundle AND admin approval. A bundle
|
||||
// uploaded by the user directly carries no signature, so it cannot be
|
||||
// privileged regardless of what its manifest claims.
|
||||
// 2. Only an admin can install a privileged plugin. A plugin the user
|
||||
// uploaded is never privileged, whatever its manifest claims; installing
|
||||
// over an admin plugin's id is refused (see installPlugin). The bundle
|
||||
// itself must carry the signature made at admin install time, which the
|
||||
// bundle download checks for privileged plugins (downloadManagedBundle).
|
||||
if (!plugin.managed) {
|
||||
return { tier: null, error: 'Privileged tier requires a signed bundle delivered through the admin channel' };
|
||||
}
|
||||
if (!(plugin.adminApproved || plugin.managed)) {
|
||||
return { tier: null, error: 'Privileged tier requires administrator approval' };
|
||||
}
|
||||
|
||||
// 4. Explicit high-risk consent. Admin-managed plugins are pre-approved by
|
||||
// the operator and skip the per-user prompt (mirrors the existing consent
|
||||
// gate in the plugin store); otherwise the user must have granted crypto:full.
|
||||
const consented = plugin.managed || (plugin.grantedPermissions ?? []).includes('crypto:full');
|
||||
if (!consented) {
|
||||
return { tier: null, error: 'Privileged tier requires explicit consent for "crypto:full"' };
|
||||
}
|
||||
|
||||
return { tier: 'privileged' };
|
||||
}
|
||||
|
||||
@@ -476,7 +476,7 @@ async function syncServerPlugins(
|
||||
|
||||
if (!local) {
|
||||
// New server plugin - download bundle and install.
|
||||
const code = await downloadPluginBundle(sp.id, sp.bundleHash);
|
||||
const code = await downloadPluginBundle(sp.id, sp.bundleHash, sp.tier);
|
||||
if (!code) continue;
|
||||
await pluginStorage.saveCode(sp.id, code);
|
||||
|
||||
@@ -521,7 +521,7 @@ async function syncServerPlugins(
|
||||
(!!sp.bundleHash && local.bundleHash !== sp.bundleHash);
|
||||
|
||||
if (needsBundle) {
|
||||
const code = await downloadPluginBundle(sp.id, sp.bundleHash);
|
||||
const code = await downloadPluginBundle(sp.id, sp.bundleHash, sp.tier);
|
||||
if (!code) continue;
|
||||
await pluginStorage.saveCode(sp.id, code);
|
||||
}
|
||||
@@ -572,9 +572,9 @@ async function syncServerPlugins(
|
||||
* The sandbox loader shares the same download path
|
||||
* (`lib/plugin-sandbox/bundle-fetch`) to refill a missing bundle at load time.
|
||||
*/
|
||||
async function downloadPluginBundle(pluginId: string, bundleHash?: string): Promise<string | null> {
|
||||
async function downloadPluginBundle(pluginId: string, bundleHash?: string, tier?: PluginTier): Promise<string | null> {
|
||||
try {
|
||||
return await downloadManagedBundle(pluginId, bundleHash);
|
||||
return await downloadManagedBundle(pluginId, bundleHash, { requireSignature: tier === 'privileged' });
|
||||
} catch (err) {
|
||||
console.error(`[plugin-store] ${err instanceof Error ? err.message : String(err)}`);
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user