Files
webmail/lib/admin/plugin-registry.ts
T
Linus Rath 022b83c005 feat: let plugins use accounts and API keys through the server without seeing them
Plugins that talk to Nextcloud, Paperless or an AI provider used to need
the password or key in their own settings, readable by the plugin and
stored in the browser. A plugin can now declare credentials in its
manifest and send requests "with credential X" through the server:

- `credentials` in the manifest: admin scope (secret and server from the
  admin config) or user scope (the user connects an account; the server
  address is fixed or chosen by the user)
- `/api/plugin-secrets`: the user's connected accounts, encrypted per
  mail account under `PLUGIN_SECRETS_DIR`; listing never returns secrets
- `/api/plugin-proxy`: adds the secret and forwards only to the
  credential's own origin, through the SSRF guard unless the admin set
  the server up or listed it in `PLUGIN_PROXY_PRIVATE_ORIGINS`; no
  redirects or WebDAV moves to other servers, streamed bodies capped at
  512 MB up and 2 GB down, 300 requests a minute per plugin, 8 at once
  per user, and one audit line per request without paths or bodies
- a capture mode that stores a secret from a sign-in response (Nextcloud
  Login Flow v2) and removes it from what the plugin gets back
2026-10-14 04:00:36 +02:00

416 lines
14 KiB
TypeScript

import type { CredentialSpec } from '@/lib/plugin-secrets/credentials';
import { readFile, writeFile, mkdir, rename, unlink } 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 { getConfigDir, assertWritable } from './paths';
function getPluginsDir(): string {
return path.join(getConfigDir(), 'plugins');
}
function getThemesDir(): string {
return path.join(getConfigDir(), 'themes');
}
// ─── Types ───────────────────────────────────────────────────
export interface PluginConfigField {
type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
label: string;
description?: string;
required?: boolean;
default?: unknown;
placeholder?: string;
options?: { label: string; value: string }[];
}
/**
* Per-user setting field, mirrors the manifest's `settingsSchema` shape
* (see lib/plugin-types.ts SettingFieldSchema). The server passes these
* through unchanged so the client can render the per-user settings UI.
*/
export interface PluginSettingsField {
type: 'boolean' | 'string' | 'number' | 'select';
label: string;
description?: string;
default: unknown;
options?: string[];
min?: number;
max?: number;
}
export interface ServerPlugin {
id: string;
name: string;
version: string;
author: string;
description: string;
type: string;
/** Requested execution tier ('untrusted' | 'privileged'). Privileged plugins
* run in a same-origin sandbox and require admin approval + consent. */
tier?: string;
permissions: string[];
entrypoint: string;
enabled: boolean;
forceEnabled?: boolean;
configSchema?: Record<string, PluginConfigField>;
settingsSchema?: Record<string, PluginSettingsField>;
/**
* Optional per-locale translation tables (locale -> key -> string) declared
* in the plugin manifest. Surfaced to the sandbox so plugin code can call
* `api.i18n.t(key)`; without it a plugin's strings stay in its hardcoded
* default language.
*/
locales?: Record<string, Record<string, string>>;
installedAt: string;
updatedAt: string;
/**
* Full SHA-256 hex of the bundle code (64 chars). Refreshed every save so
* the same version re-uploaded with new code still appears as a change to
* the client. Also doubles as the HTTP ETag for the bundle endpoint and is
* verified by the sandbox loader on every load
* (`lib/plugin-sandbox/bundle-integrity.ts`), so it must match the served
* bytes exactly.
*/
bundleHash?: string;
/**
* Validated CSP origins (https-only, single-origin form) the plugin may
* embed. Merged into the host frame-src by the proxy.
*/
frameOrigins?: string[];
/**
* Validated HTTPS origins the plugin may target via `api.http.fetch()`.
* Same syntax as `frameOrigins`. Surfaced to clients via /api/plugins.
*/
httpOrigins?: string[];
/**
* Same-origin `/api/*` path allowlist for `api.http.post()`. See
* `InstalledPlugin.apiPostPaths` in `lib/plugin-types.ts`.
*/
apiPostPaths?: string[];
/** Credentials the plugin uses through the server proxy (no secrets here). */
credentials?: Record<string, CredentialSpec>;
}
export interface ServerTheme {
id: string;
name: string;
version: string;
author: string;
description: string;
variants: string[];
enabled: boolean;
forceEnabled?: boolean;
installedAt: string;
updatedAt: string;
/** True when a Theme API v2 skin.css is stored next to the CSS. */
hasSkin?: boolean;
/**
* SHA-256 of the CSS and skin. Clients use it to notice a re-upload of
* the same version and as a cache key on the CSS and skin URLs.
*/
contentHash?: string;
}
interface PluginRegistry {
plugins: ServerPlugin[];
}
interface ThemeRegistry {
themes: ServerTheme[];
}
// ─── Plugin Registry ─────────────────────────────────────────
async function ensureDir(dir: string): Promise<void> {
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
}
}
async function readJsonFile<T>(filePath: string, fallback: T): Promise<T> {
try {
const raw = await readFile(filePath, 'utf-8');
return JSON.parse(raw);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return fallback;
logger.warn(`Failed to read ${filePath}`, { error: error instanceof Error ? error.message : 'Unknown error' });
return fallback;
}
}
async function writeJsonFile(filePath: string, data: unknown): Promise<void> {
const dir = path.dirname(filePath);
await ensureDir(dir);
const tmpPath = filePath + '.tmp';
await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8');
await rename(tmpPath, filePath);
}
// ─── Plugin Operations ───────────────────────────────────────
const pluginRegistryPath = () => path.join(getPluginsDir(), 'registry.json');
const FULL_HASH_RE = /^[0-9a-f]{64}$/;
/**
* Older builds wrote a 16-char SHA-256 prefix into `bundleHash`. The current
* client-side verifyBundle requires equal-length hex (and the full digest for
* real integrity), so any registry entry with a truncated or otherwise
* malformed hash needs to be re-hashed from the on-disk bundle. If the bundle
* file is missing the hash is cleared so verifyBundle skips the check rather
* than refusing to load.
*/
async function migrateBundleHashes(registry: PluginRegistry): Promise<boolean> {
let changed = false;
for (const plugin of registry.plugins) {
if (!plugin.bundleHash || FULL_HASH_RE.test(plugin.bundleHash)) continue;
const bundlePath = path.join(getPluginsDir(), `${plugin.id}.js`);
try {
const code = await readFile(bundlePath);
plugin.bundleHash = createHash('sha256').update(code).digest('hex');
} catch {
delete plugin.bundleHash;
}
changed = true;
}
return changed;
}
export async function getPluginRegistry(): Promise<PluginRegistry> {
const registry = await readJsonFile<PluginRegistry>(pluginRegistryPath(), { plugins: [] });
if (await migrateBundleHashes(registry)) {
try { await writeJsonFile(pluginRegistryPath(), registry); } catch { /* read-only fs ok */ }
}
return registry;
}
export async function getPlugin(id: string): Promise<ServerPlugin | null> {
const registry = await getPluginRegistry();
return registry.plugins.find(p => p.id === id) || null;
}
const SAFE_ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
export async function savePlugin(
plugin: ServerPlugin,
code: string,
): Promise<void> {
assertWritable('install plugin');
if (!SAFE_ID_RE.test(plugin.id)) {
throw new Error('Invalid plugin id');
}
const dir = getPluginsDir();
await ensureDir(dir);
// 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
// installedAt across re-uploads. The full SHA-256 is required because the
// client-side verifyBundle compares the entire digest length-checked.
const bundleHash = createHash('sha256').update(code).digest('hex');
const now = new Date().toISOString();
const registry = await getPluginRegistry();
const idx = registry.plugins.findIndex(p => p.id === plugin.id);
const next: ServerPlugin = {
...plugin,
bundleHash,
updatedAt: now,
installedAt: idx >= 0 ? registry.plugins[idx].installedAt : plugin.installedAt,
};
if (idx >= 0) {
registry.plugins[idx] = next;
} else {
registry.plugins.push(next);
}
await writeJsonFile(pluginRegistryPath(), registry);
}
export async function updatePluginMeta(id: string, updates: Partial<Pick<ServerPlugin, 'enabled' | 'forceEnabled'>>): Promise<ServerPlugin | null> {
assertWritable('update plugin metadata');
const registry = await getPluginRegistry();
const idx = registry.plugins.findIndex(p => p.id === id);
if (idx < 0) return null;
registry.plugins[idx] = { ...registry.plugins[idx], ...updates, updatedAt: new Date().toISOString() };
await writeJsonFile(pluginRegistryPath(), registry);
return registry.plugins[idx];
}
export async function deletePlugin(id: string): Promise<boolean> {
assertWritable('delete plugin');
const registry = await getPluginRegistry();
const idx = registry.plugins.findIndex(p => p.id === id);
if (idx < 0) return false;
registry.plugins.splice(idx, 1);
await writeJsonFile(pluginRegistryPath(), registry);
// 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 {
return await readFile(bundlePath, 'utf-8');
} catch {
return null;
}
}
// ─── Theme Operations ────────────────────────────────────────
const themeRegistryPath = () => path.join(getThemesDir(), 'registry.json');
export async function getThemeRegistry(): Promise<ThemeRegistry> {
return readJsonFile<ThemeRegistry>(themeRegistryPath(), { themes: [] });
}
export async function getTheme(id: string): Promise<ServerTheme | null> {
const registry = await getThemeRegistry();
return registry.themes.find(t => t.id === id) || null;
}
export async function saveTheme(
theme: ServerTheme,
css: string,
skin: string | null = null,
): Promise<void> {
assertWritable('install theme');
if (!SAFE_ID_RE.test(theme.id)) {
throw new Error('Invalid theme id');
}
const dir = getThemesDir();
await ensureDir(dir);
// Save CSS file
const cssPath = path.join(dir, `${theme.id}.css`);
await writeFile(cssPath, css, 'utf-8');
const skinPath = path.join(dir, `${theme.id}.skin.css`);
if (skin) {
await writeFile(skinPath, skin, 'utf-8');
} else {
try { await unlink(skinPath); } catch { /* none stored */ }
}
theme = {
...theme,
hasSkin: !!skin,
contentHash: createHash('sha256').update(css).update('\0').update(skin ?? '').digest('hex'),
};
// Update registry
const registry = await getThemeRegistry();
const idx = registry.themes.findIndex(t => t.id === theme.id);
if (idx >= 0) {
registry.themes[idx] = theme;
} else {
registry.themes.push(theme);
}
await writeJsonFile(themeRegistryPath(), registry);
}
export async function updateThemeMeta(id: string, updates: Partial<Pick<ServerTheme, 'enabled' | 'forceEnabled'>>): Promise<ServerTheme | null> {
assertWritable('update theme metadata');
const registry = await getThemeRegistry();
const idx = registry.themes.findIndex(t => t.id === id);
if (idx < 0) return null;
registry.themes[idx] = { ...registry.themes[idx], ...updates, updatedAt: new Date().toISOString() };
await writeJsonFile(themeRegistryPath(), registry);
return registry.themes[idx];
}
export async function deleteTheme(id: string): Promise<boolean> {
assertWritable('delete theme');
const registry = await getThemeRegistry();
const idx = registry.themes.findIndex(t => t.id === id);
if (idx < 0) return false;
registry.themes.splice(idx, 1);
await writeJsonFile(themeRegistryPath(), registry);
// Remove CSS and skin files
for (const file of [`${id}.css`, `${id}.skin.css`]) {
try { await unlink(path.join(getThemesDir(), file)); } catch { /* ok if missing */ }
}
return true;
}
export async function getThemeSkin(id: string): Promise<string | null> {
if (!SAFE_ID_RE.test(id)) return null;
try {
return await readFile(path.join(getThemesDir(), `${id}.skin.css`), 'utf-8');
} catch {
return null;
}
}
/**
* CSS of every installed theme and every built-in theme, keyed by id, for
* resolving a new theme's `extends`.
*/
export async function themeCSSResolver(): Promise<(id: string) => string | null> {
const { BUILTIN_THEMES } = await import('@/lib/builtin-themes');
const map = new Map<string, string>();
for (const t of BUILTIN_THEMES) if (t.css) map.set(t.id, t.css);
for (const t of (await getThemeRegistry()).themes) {
const css = await getThemeCSS(t.id);
if (css) map.set(t.id, css);
}
return (id) => map.get(id) ?? null;
}
export async function getThemeCSS(id: string): Promise<string | null> {
const cssPath = path.join(getThemesDir(), `${id}.css`);
try {
return await readFile(cssPath, 'utf-8');
} catch {
return null;
}
}