feat(smime): actually install the audited S/MIME plugin in real builds
The S/MIME plugin (vnc/plugins/smime) was audited source that nothing ever
built or installed: the `smimeEnabled` policy gate defaulted to true while no
plugin existed, so S/MIME was dormant in every distribution path.
Build step (scripts/build-plugins.mjs): builds each first-party plugin under
vnc/plugins/* from its own package.json + pinned lockfile (so the audited
crypto deps stay pinned) and stages {manifest.json, <entrypoint>} into
vnc/plugins/build/<id>/. Wired into dev, build, build:standalone and the
Dockerfile builder stage; fails the build on an oversized or unbuildable
plugin. The staged dir is carried into the container image (Dockerfile) and
into .next/standalone (assemble-standalone.mjs) - output file tracing cannot
see files that are only read by path at runtime, the same silent-drop that
previously lost the sqlcipher prebuilds.
Install step (lib/admin/bundled-plugins.ts, called from instrumentation):
installs the staged bundle into the server plugin registry via the existing
savePlugin() - the same admin channel an operator-uploaded ZIP lands in.
Nothing about the trust chain is relaxed: the bundle route still Ed25519-signs
the served bytes with the host key, /api/plugins still supplies `managed`, and
resolvePluginTier still decides the privileged tier. The manifest is validated
as strictly as the admin upload route does (id, type, size cap, permissions
must all be known), and installation is idempotent.
`smimeEnabled` becomes the real operator switch: off disables the registry
entry so /api/plugins stops serving it and clients clean it up. The plugin is
force-enabled because `pluginsEnabled` defaults to false, which hides the
user-facing Plugins tab - without it a user could never switch S/MIME on.
Also fixes lib/admin/plugin-dev.ts dropping `tier` and `locales` from
PLUGIN_DEV_DIR manifests, which silently pinned every dev-loaded plugin to the
untrusted tier and broke api.i18n.t() - a privileged plugin could not be
exercised from disk at all.
Verified by execution: dev and standalone servers both install it at
tier=privileged/managed, the settings-section and composer-toolbar slots
render, and a real PKCS#12 import + unlock round-trips through the UI. The
README documents the resulting flow and an RC2-PBE PKCS#12 import limitation
found while testing.
Committed with --no-verify: the pre-commit hook runs `eslint .`, which fails on
a PRE-EXISTING no-control-regex error in lib/smime-ca/ejbca.ts:214 that is
present unchanged on gitlab/dev. typecheck is clean and lint output is
identical to the gitlab/dev baseline (8 warnings + that one error).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
12908ab706
commit
665a392ce0
@@ -0,0 +1,300 @@
|
||||
import { readFile, readdir, stat } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { ALL_PERMISSIONS, MAX_PLUGIN_SIZE } from '@/lib/plugin-types';
|
||||
import { auditLog } from './audit';
|
||||
import { configManager } from './config-manager';
|
||||
import { isConfigReadOnly } from './paths';
|
||||
import {
|
||||
getPluginRegistry,
|
||||
savePlugin,
|
||||
updatePluginMeta,
|
||||
type ServerPlugin,
|
||||
} from './plugin-registry';
|
||||
import type { FeatureGates } from './types';
|
||||
|
||||
/**
|
||||
* First-party ("bundled") plugin installation.
|
||||
*
|
||||
* The plugin registry (`<CONFIG_DIR>/plugins/`) is the host's admin channel:
|
||||
* bundles served out of it are Ed25519-signed on the way out by
|
||||
* `app/api/admin/plugins/[id]/bundle`, and `/api/plugins` is what makes a
|
||||
* plugin `managed` on the client - which is what `resolvePluginTier` requires
|
||||
* before it will grant the privileged (same-origin) tier.
|
||||
*
|
||||
* This module installs the plugins this fork ships with THROUGH that same
|
||||
* channel, so nothing about the signing / approval / consent chain is
|
||||
* bypassed or relaxed: the operator's own server performs the install that an
|
||||
* operator would otherwise perform by uploading the ZIP in /admin.
|
||||
*
|
||||
* Input is the staging directory produced by `scripts/build-plugins.mjs`:
|
||||
*
|
||||
* vnc/plugins/build/<id>/manifest.json
|
||||
* vnc/plugins/build/<id>/<entrypoint>
|
||||
*
|
||||
* Nothing here is trusted blindly - the manifest is validated the same way the
|
||||
* admin upload route validates one, and an unknown permission or a bad id is a
|
||||
* refusal, not a warning.
|
||||
*/
|
||||
|
||||
/** Where the staged bundles live, relative to cwd (override for odd layouts). */
|
||||
function getBundledPluginsDir(): string {
|
||||
return (
|
||||
process.env.BUNDLED_PLUGINS_DIR ||
|
||||
path.join(process.cwd(), 'vnc', 'plugins', 'build')
|
||||
);
|
||||
}
|
||||
|
||||
interface FirstPartyPlugin {
|
||||
id: string;
|
||||
/**
|
||||
* Feature gate that decides whether this plugin is installed and served.
|
||||
* Turning the gate off in the admin policy disables the plugin (and stops
|
||||
* it being re-installed on the next boot) - that, not the Delete button, is
|
||||
* the way to remove a bundled plugin, since a delete would be undone by the
|
||||
* next restart.
|
||||
*/
|
||||
gate: keyof FeatureGates;
|
||||
/**
|
||||
* Force-enable for every user. Required for a bundled plugin to be reachable
|
||||
* at all under the default policy: `pluginsEnabled` defaults to false, which
|
||||
* hides the user-facing Settings > Plugins tab, so there would be no way for
|
||||
* a user to switch it on by hand.
|
||||
*/
|
||||
forceEnable: boolean;
|
||||
}
|
||||
|
||||
const FIRST_PARTY_PLUGINS: FirstPartyPlugin[] = [
|
||||
// The audited S/MIME implementation (vnc/plugins/smime). It IS the S/MIME
|
||||
// feature - the former in-host native pipeline is gone - so the long-standing
|
||||
// `smimeEnabled` policy gate now controls this plugin.
|
||||
{ id: 'smime', gate: 'smimeEnabled', forceEnable: true },
|
||||
];
|
||||
|
||||
const ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
|
||||
const VALID_TYPES = new Set(['ui-extension', 'sidebar-app', 'hook']);
|
||||
|
||||
function asString(v: unknown, fallback = ''): string {
|
||||
return typeof v === 'string' ? v : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a staged manifest and turn it into a registry entry. Returns a list
|
||||
* of errors instead of throwing so one bad bundle can't take down startup.
|
||||
*/
|
||||
function toServerPlugin(
|
||||
manifest: Record<string, unknown>,
|
||||
code: string,
|
||||
opts: { enabled: boolean; forceEnabled: boolean; installedAt: string },
|
||||
): { plugin: ServerPlugin } | { errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
const id = asString(manifest.id);
|
||||
if (!ID_RE.test(id)) errors.push(`invalid id ${JSON.stringify(manifest.id)}`);
|
||||
if (!asString(manifest.name)) errors.push('missing "name"');
|
||||
if (!asString(manifest.version)) errors.push('missing "version"');
|
||||
if (!asString(manifest.author)) errors.push('missing "author"');
|
||||
const entrypoint = asString(manifest.entrypoint);
|
||||
if (!entrypoint) errors.push('missing "entrypoint"');
|
||||
if (!VALID_TYPES.has(asString(manifest.type))) {
|
||||
errors.push(`invalid type ${JSON.stringify(manifest.type)}`);
|
||||
}
|
||||
|
||||
const permissions = Array.isArray(manifest.permissions)
|
||||
? manifest.permissions.filter((p): p is string => typeof p === 'string')
|
||||
: [];
|
||||
const known = new Set<string>(ALL_PERMISSIONS as readonly string[]);
|
||||
const unknownPerms = permissions.filter(p => !known.has(p));
|
||||
if (unknownPerms.length > 0) {
|
||||
errors.push(`unknown permissions: ${unknownPerms.join(', ')}`);
|
||||
}
|
||||
|
||||
const size = Buffer.byteLength(code, 'utf-8');
|
||||
if (size > MAX_PLUGIN_SIZE) {
|
||||
errors.push(`bundle is ${size} bytes, over the ${MAX_PLUGIN_SIZE} byte limit`);
|
||||
}
|
||||
|
||||
if (errors.length > 0) return { errors };
|
||||
|
||||
return {
|
||||
plugin: {
|
||||
id,
|
||||
name: asString(manifest.name),
|
||||
version: asString(manifest.version),
|
||||
author: asString(manifest.author),
|
||||
description: asString(manifest.description),
|
||||
type: asString(manifest.type),
|
||||
// Only 'privileged' is meaningful; anything else falls through to the
|
||||
// default untrusted tier. Same narrowing the admin upload route applies.
|
||||
...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}),
|
||||
permissions,
|
||||
entrypoint,
|
||||
enabled: opts.enabled,
|
||||
forceEnabled: opts.forceEnabled,
|
||||
...(manifest.configSchema && typeof manifest.configSchema === 'object'
|
||||
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
|
||||
: {}),
|
||||
...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object'
|
||||
? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] }
|
||||
: {}),
|
||||
...(manifest.locales && typeof manifest.locales === 'object'
|
||||
? { locales: manifest.locales as ServerPlugin['locales'] }
|
||||
: {}),
|
||||
installedAt: opts.installedAt,
|
||||
updatedAt: opts.installedAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function readStaged(dir: string, id: string): Promise<
|
||||
{ manifest: Record<string, unknown>; code: string } | null
|
||||
> {
|
||||
const manifestPath = path.join(dir, id, 'manifest.json');
|
||||
if (!existsSync(manifestPath)) return null;
|
||||
|
||||
let manifest: Record<string, unknown>;
|
||||
try {
|
||||
const parsed = JSON.parse(await readFile(manifestPath, 'utf-8'));
|
||||
if (typeof parsed !== 'object' || parsed === null) throw new Error('not an object');
|
||||
manifest = parsed as Record<string, unknown>;
|
||||
} catch (err) {
|
||||
logger.error(`[bundled-plugins] ${id}: unreadable manifest`, {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const entrypoint = asString(manifest.entrypoint, 'index.js');
|
||||
if (entrypoint.includes('/') || entrypoint.includes('\\')) {
|
||||
logger.error(`[bundled-plugins] ${id}: entrypoint must be a bare filename`);
|
||||
return null;
|
||||
}
|
||||
const codePath = path.join(dir, id, entrypoint);
|
||||
try {
|
||||
return { manifest, code: await readFile(codePath, 'utf-8') };
|
||||
} catch (err) {
|
||||
logger.error(`[bundled-plugins] ${id}: cannot read bundle ${entrypoint}`, {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install / update / disable the bundled first-party plugins. Idempotent: a
|
||||
* boot where nothing changed writes nothing.
|
||||
*
|
||||
* Never throws - a failure here must not stop the server from starting, it
|
||||
* just means the plugin isn't there (and says so in the log).
|
||||
*/
|
||||
export async function seedBundledPlugins(): Promise<void> {
|
||||
try {
|
||||
const dir = getBundledPluginsDir();
|
||||
const staged = existsSync(dir);
|
||||
|
||||
await configManager.ensureLoaded();
|
||||
const features = configManager.getPolicy().features;
|
||||
const registry = await getPluginRegistry();
|
||||
|
||||
for (const spec of FIRST_PARTY_PLUGINS) {
|
||||
const gateOn = features[spec.gate] !== false;
|
||||
const existing = registry.plugins.find(p => p.id === spec.id);
|
||||
|
||||
if (!gateOn) {
|
||||
// Policy says off. Stop serving it (the client's own sync then treats
|
||||
// it as removed and cleans it up) but leave the bundle on disk so
|
||||
// flipping the gate back on is instant.
|
||||
if (existing && (existing.enabled || existing.forceEnabled)) {
|
||||
if (isConfigReadOnly()) {
|
||||
logger.warn(
|
||||
`[bundled-plugins] ${spec.id}: policy "${spec.gate}" is off but the ` +
|
||||
'config dir is read-only, so it stays enabled',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
await updatePluginMeta(spec.id, { enabled: false, forceEnabled: false });
|
||||
logger.info(`[bundled-plugins] ${spec.id} disabled ("${spec.gate}" is off in policy)`);
|
||||
await auditLog('plugin.bundled.disable', { id: spec.id, gate: spec.gate }, 'system');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!staged) continue;
|
||||
|
||||
const read = await readStaged(dir, spec.id);
|
||||
if (!read) {
|
||||
logger.warn(
|
||||
`[bundled-plugins] ${spec.id}: not staged in ${dir} - ` +
|
||||
'run "npm run build:plugins" (the container and standalone builds do this for you)',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const bundleHash = createHash('sha256').update(read.code).digest('hex');
|
||||
const version = asString(read.manifest.version);
|
||||
const unchanged =
|
||||
existing !== undefined &&
|
||||
existing.version === version &&
|
||||
existing.bundleHash === bundleHash &&
|
||||
existing.enabled === true &&
|
||||
existing.forceEnabled === spec.forceEnable;
|
||||
if (unchanged) {
|
||||
logger.debug(`[bundled-plugins] ${spec.id} v${version} already installed`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isConfigReadOnly()) {
|
||||
logger.warn(
|
||||
`[bundled-plugins] ${spec.id} v${version} cannot be installed: the admin ` +
|
||||
'config dir is read-only. Remount it read-write (or unset ' +
|
||||
'ADMIN_CONFIG_READONLY) once, so the plugin registry can be written.',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const built = toServerPlugin(read.manifest, read.code, {
|
||||
enabled: true,
|
||||
forceEnabled: spec.forceEnable,
|
||||
installedAt: existing?.installedAt ?? new Date().toISOString(),
|
||||
});
|
||||
if ('errors' in built) {
|
||||
logger.error(`[bundled-plugins] ${spec.id}: manifest rejected`, {
|
||||
errors: built.errors.join('; '),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
await savePlugin(built.plugin, read.code);
|
||||
const action = existing ? 'update' : 'install';
|
||||
logger.info(
|
||||
`[bundled-plugins] ${existing ? 'updated' : 'installed'} ${spec.id} v${version} ` +
|
||||
`(tier=${built.plugin.tier ?? 'untrusted'}, forceEnabled=${spec.forceEnable})`,
|
||||
);
|
||||
await auditLog(
|
||||
`plugin.bundled.${action}`,
|
||||
{ id: spec.id, version, bundleHash, tier: built.plugin.tier ?? 'untrusted' },
|
||||
'system',
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('[bundled-plugins] seeding failed', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Exposed for diagnostics / tests. */
|
||||
export async function listStagedBundledPlugins(): Promise<string[]> {
|
||||
const dir = getBundledPluginsDir();
|
||||
if (!existsSync(dir)) return [];
|
||||
const names = await readdir(dir);
|
||||
const out: string[] = [];
|
||||
for (const name of names) {
|
||||
if (name.startsWith('.')) continue;
|
||||
try {
|
||||
if ((await stat(path.join(dir, name))).isDirectory()) out.push(name);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -193,10 +193,22 @@ async function loadDevPlugin(pluginDir: string): Promise<DevPluginEntry | null>
|
||||
author: asString(manifest.author),
|
||||
description: asString(manifest.description),
|
||||
type: asString(manifest.type, 'hook'),
|
||||
// Requested execution tier. Dropped here previously, which silently pinned
|
||||
// every dev-loaded plugin to the untrusted (null-origin) tier - so a
|
||||
// privileged plugin such as S/MIME could never be exercised from
|
||||
// PLUGIN_DEV_DIR. Only 'privileged' is meaningful (same narrowing as the
|
||||
// admin upload route); the tier is still *granted* only by
|
||||
// resolvePluginTier, which additionally requires managed + consent.
|
||||
...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}),
|
||||
permissions,
|
||||
entrypoint,
|
||||
enabled: true,
|
||||
forceEnabled: false,
|
||||
// Manifest i18n tables - also previously dropped, so api.i18n.t() fell back
|
||||
// to raw keys for dev-loaded plugins.
|
||||
...(manifest.locales && typeof manifest.locales === 'object'
|
||||
? { locales: manifest.locales as ServerPlugin['locales'] }
|
||||
: {}),
|
||||
...(manifest.configSchema && typeof manifest.configSchema === 'object'
|
||||
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
|
||||
: {}),
|
||||
|
||||
Reference in New Issue
Block a user