super-productivity/electron/plugin-node-executor.ts
Johannes Millan a67323b2b5
feat(plugins): allow uploaded nodeExecution behind consent gate (#8576)
* feat(plugins): allow uploaded nodeExecution behind consent gate

Re-open the nodeExecution permission for uploaded/community plugins
(previously built-in only, #8205) behind the existing main-process
consent dialog. Phase 1 of #8512; unblocks the Super Productivity MCP
plugin (discussion #8385).

- Main process sanitizes the attacker-controlled plugin id and the
  self-declared name/version before they reach the consent dialog or
  the grant map (control/bidi/whitespace rejected, length-capped).
- Bundled vs uploaded is decided by the on-disk manifest, never a
  renderer-supplied flag, so uploaded code can't borrow a built-in
  plugin's verified name.
- Uploaded-plugin dialog anchors on the validated id, flags the plugin
  as unverified third-party with full machine access / no sandbox, and
  defaults to Deny.
- Revoke is main-authoritative by (pluginId, webContents) so a re-upload
  reusing an id can't inherit a live session grant.
- Consent stays session-scoped: an in-memory, never-synced denied set
  prevents re-prompt storms; deny keeps the plugin enabled but fails
  node calls closed until re-enable or restart.

Refs #8512 #8385

* fix(plugins): reject path-segment ids in nodeExecution consent gate

Multi-agent review of the Phase 1 gate found the uploaded plugin id —
used as a path component in the bundled-manifest existsSync probe — was
not rejecting path separators or dot-segments. Impact was bounded (the
verified-builtin branch re-validates with the strict kebab regex before
any read, so no code exec / file read / dialog spoof), but `..` / `/`
left a filesystem-existence oracle and rendered misleadingly as the
dialog "Plugin ID". assertSafePluginId now rejects `/`, `\`, `.`, `..`.

Also from review: factor the shared Allow/Deny dialog shell, and correct
the denied-cache comment (the existing token short-circuit handles the
multi-call-site case; the cache only makes a denial sticky across a later
non-interactive grant re-entry). Documents the uploaded-id constraints.

Refs #8512

* fix(plugins): harden uploaded nodeExecution consent gate (review follow-ups)

Addresses multi-agent review findings on the uploaded-plugin nodeExecution
consent gate:

- id validation: use an allowlist (/^[A-Za-z0-9][A-Za-z0-9._-]*$/) instead of a
  Unicode denylist, closing bidi/zero-width/homoglyph dialog-anchor spoofing the
  range list missed (U+061C, U+2060, U+3164, fullwidth chars); strip all Unicode
  control+format chars from the self-declared display name/version.
- never upgrade trust: describeVerifiedBuiltInDialog returns null on any imperfect
  on-disk verification (id mismatch, missing permission, unreadable manifest) and
  the grant handler falls back to the unverified dialog, so a colliding uploaded id
  can never borrow a built-in's verified dialog.
- reserve the gitea/linear/trello/azure-devops issue-provider bundled ids (they had
  drifted out of BUNDLED_PLUGIN_IDS, leaving an impersonation gap) and guard the
  BUNDLED_PLUGIN_PATHS subset-of BUNDLED_PLUGIN_IDS invariant with a node test.
- key the revoke and exec IPC handlers through the same assertSafePluginId as the
  grant handler so the "revoke by id on teardown/re-upload" guarantee can't drift.
- clear the session nodeExecution denial when a plugin is uninstalled, so a fresh
  re-upload of the same id is prompted again rather than silently failing closed.
- de-duplicate the PluginNodeExecutionElectronApi interface into a single
  electron/shared-with-frontend model (was copied byte-identically in two files).
2026-06-25 16:45:54 +02:00

616 lines
21 KiB
TypeScript

import { app, BrowserWindow, dialog, ipcMain } from 'electron';
import type { WebContents } from 'electron';
import { spawn } from 'child_process';
import { randomBytes } from 'crypto';
import { existsSync, readFileSync } from 'fs';
import { join, resolve as resolvePath } from 'path';
import * as vm from 'vm';
import { IPC } from './shared-with-frontend/ipc-events.const';
import {
PluginNodeScriptRequest,
PluginNodeScriptResult,
PluginManifest,
} from '../packages/plugin-api/src/types';
const DEFAULT_TIMEOUT = 30000; // 30 seconds
const MAX_TIMEOUT = 300000; // 5 minutes
const BUILT_IN_PLUGIN_ID_RE = /^[a-z0-9][a-z0-9-]*$/;
// An uploaded (community) plugin id is attacker-controlled and used both as a grant Map
// key and as the consent dialog's trust anchor ("Plugin ID: ..."), and as a path segment
// in getBuiltInManifestPath(). It is NOT held to the strict built-in kebab rule —
// community ids may use dots/uppercase, e.g. `super-productivity-mcp` — but it must be a
// single safe ASCII token. We use an allowlist rather than a denylist on purpose: the
// allowlist rejects control/zero-width/bidi/homoglyph characters that could spoof the
// dialog, whitespace that could inject extra dialog lines, the ':' persistence delimiter,
// and path separators / leading-dot segments ('.', '..', '/', '\\') — all by construction,
// with no Unicode range to keep updated as new code points are assigned.
const MAX_UPLOADED_PLUGIN_ID_LENGTH = 100;
const SAFE_UPLOADED_PLUGIN_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
// Self-declared name/version are display-only. Strip every Unicode control (Cc) and
// format (Cf) character — this covers C0/C1 controls, all zero-width characters, the BOM,
// and every bidi control (incl. U+061C ALM, the word-joiner range, and the isolate marks)
// without enumerating ranges — then collapse whitespace so a crafted value cannot inject
// extra dialog lines. (Global flag is for replace, not test, so no lastIndex statefulness.)
const UNSAFE_DISPLAY_CHARS_RE = /[\p{Cc}\p{Cf}]/gu;
const assertSafePluginId = (pluginId: unknown): string => {
if (typeof pluginId !== 'string' || pluginId.length === 0) {
throw new Error('Invalid pluginId');
}
if (pluginId.length > MAX_UPLOADED_PLUGIN_ID_LENGTH) {
throw new Error('Invalid pluginId');
}
// Allowlist match also rejects path separators ('/'/'\\'), leading-dot segments
// ('.', '..'), ':', whitespace and all non-ASCII (bidi/zero-width/homoglyph), so the id
// can neither escape the bundled-plugins dir in getBuiltInManifestPath() nor spoof the
// consent dialog's trust anchor.
if (!SAFE_UPLOADED_PLUGIN_ID_RE.test(pluginId)) {
throw new Error('Invalid pluginId');
}
return pluginId;
};
const sanitizeDialogString = (value: unknown, maxLength: number): string => {
if (typeof value !== 'string') {
return '';
}
const cleaned = value.replace(UNSAFE_DISPLAY_CHARS_RE, '').replace(/\s+/g, ' ').trim();
return cleaned.length > maxLength ? `${cleaned.slice(0, maxLength)}` : cleaned;
};
// Shared shell for both nodeExecution consent dialogs: a warning with Allow/Deny where
// Deny is the default + cancel action, so a reflexive Enter/Escape denies.
const NODE_CONSENT_DIALOG_BASE: Pick<
Electron.MessageBoxOptions,
'type' | 'buttons' | 'defaultId' | 'cancelId'
> = {
type: 'warning',
buttons: ['Allow', 'Deny'],
defaultId: 1,
cancelId: 1,
};
interface NodeExecutionGrant {
token: string;
webContentsId: number;
}
/** Self-declared, unverified display metadata supplied by the renderer for uploaded plugins. */
interface NodeExecutionGrantDisplayInfo {
name?: string;
version?: string;
}
interface WebContentsGrantCleanup {
webContents: WebContents;
cleanup: () => void;
didStartNavigation: (
event: Electron.Event,
url: string,
isInPlace: boolean,
isMainFrame: boolean,
) => void;
}
class PluginNodeExecutor {
/**
* Main-owned per-session execution grants. Renderer code may request a grant,
* but it cannot set one silently; the main process issues the token only after
* a native consent dialog. Execution never trusts renderer-provided manifests.
*/
private readonly grants = new Map<string, NodeExecutionGrant>();
private readonly grantCleanupByWebContents = new Map<number, WebContentsGrantCleanup>();
constructor() {
this.setupIpcHandler();
}
private setupIpcHandler(): void {
ipcMain.handle(
IPC.PLUGIN_REQUEST_NODE_EXECUTION_GRANT,
async (event, pluginId: string, displayInfo?: NodeExecutionGrantDisplayInfo) => {
const window = BrowserWindow.fromWebContents(event.sender);
if (!window) {
throw new Error('No window found for event sender');
}
// Sanitize first: the id is used as a grant Map key AND shown in the consent
// dialog, and for uploaded plugins it is attacker-controlled.
const safeId = assertSafePluginId(pluginId);
const webContentsId = event.sender.id;
const existingGrant = this.grants.get(safeId);
if (existingGrant) {
if (existingGrant.webContentsId === webContentsId) {
return { token: existingGrant.token };
}
this.grants.delete(safeId);
this.releaseGrantCleanupIfUnused(existingGrant.webContentsId);
}
// Bundled vs uploaded is decided by the main-owned filesystem, never by a
// renderer-supplied flag, and only an id that resolves to a cleanly-verified
// on-disk manifest gets the trusted built-in dialog. A partial or colliding match
// (id mismatch, missing nodeExecution permission, unreadable manifest) returns
// null and falls back to the unverified dialog, so uploaded code can never borrow
// a built-in plugin's trusted name even if its id collides with a bundled dir.
const dialogOptions =
this.describeVerifiedBuiltInDialog(safeId) ??
this.describeUnverifiedUploadedDialog(safeId, displayInfo);
const requestUrl = event.sender.getURL();
this.registerGrantCleanup(event.sender);
let result: Electron.MessageBoxReturnValue;
try {
result = await dialog.showMessageBox(window, dialogOptions);
} catch (error) {
this.releaseGrantCleanupIfUnused(webContentsId);
throw error;
}
if (
event.sender.isDestroyed() ||
!this.grantCleanupByWebContents.has(webContentsId) ||
event.sender.getURL() !== requestUrl
) {
this.grants.delete(safeId);
this.releaseGrantCleanupIfUnused(webContentsId);
return null;
}
if (result.response !== 0) {
this.grants.delete(safeId);
this.releaseGrantCleanupIfUnused(webContentsId);
return null;
}
const token = randomBytes(32).toString('base64url');
this.grants.set(safeId, {
token,
webContentsId,
});
return { token };
},
);
ipcMain.handle(
IPC.PLUGIN_REVOKE_NODE_EXECUTION_GRANT,
// grantToken is accepted for signature compatibility but intentionally not
// required: revoking only removes a capability, and the issuing window must be
// able to drop its own grant during teardown even if it no longer holds the
// token (e.g. on re-upload) — otherwise a re-uploaded plugin reusing the id
// could inherit a live session grant. The webContents binding still prevents
// another window from revoking this one's grant.
(event, pluginId: string, _grantToken?: string) => {
// Key the lookup through the same validator the request handler uses, so the
// "always revoke by id on teardown/re-upload" guarantee holds even if the id
// canonicalisation ever changes (an unsafe id can never hold a grant anyway).
let safeId: string;
try {
safeId = assertSafePluginId(pluginId);
} catch {
return;
}
const grant = this.grants.get(safeId);
if (grant && grant.webContentsId === event.sender.id) {
this.grants.delete(safeId);
}
},
);
ipcMain.handle(
IPC.PLUGIN_EXEC_NODE_SCRIPT,
async (
event,
pluginId: string,
grantToken: string,
request: PluginNodeScriptRequest,
) => {
const window = BrowserWindow.fromWebContents(event.sender);
if (!window) {
throw new Error('No window found for event sender');
}
// Validate the id the same way the grant handler does so the Map keys match.
// An unsafe id can never hold a grant, so treat it as unauthorized.
let safeId: string;
try {
safeId = assertSafePluginId(pluginId);
} catch {
throw new Error('Plugin is not authorized for nodeExecution');
}
const grant = this.grants.get(safeId);
if (
!grant ||
grant.token !== grantToken ||
grant.webContentsId !== event.sender.id
) {
throw new Error('Plugin is not authorized for nodeExecution');
}
return await this.executeScript(safeId, request);
},
);
}
private registerGrantCleanup(webContents: WebContents): void {
const webContentsId = webContents.id;
if (this.grantCleanupByWebContents.has(webContentsId)) {
return;
}
const cleanup = (): void => {
for (const [pluginId, grant] of this.grants.entries()) {
if (grant.webContentsId === webContentsId) {
this.grants.delete(pluginId);
}
}
const registration = this.grantCleanupByWebContents.get(webContentsId);
if (registration) {
this.unregisterGrantCleanup(webContentsId);
}
};
const didStartNavigation = (
_event: Electron.Event,
_url: string,
isInPlace: boolean,
isMainFrame: boolean,
): void => {
if (isMainFrame && !isInPlace) {
cleanup();
}
};
this.grantCleanupByWebContents.set(webContentsId, {
webContents,
cleanup,
didStartNavigation,
});
webContents.once('destroyed', cleanup);
webContents.on('will-navigate', cleanup);
webContents.on('did-navigate', cleanup);
webContents.on('did-start-navigation', didStartNavigation);
}
private unregisterGrantCleanup(webContentsId: number): void {
const registration = this.grantCleanupByWebContents.get(webContentsId);
if (!registration) {
return;
}
const { webContents } = registration;
webContents.removeListener('destroyed', registration.cleanup);
webContents.removeListener('will-navigate', registration.cleanup);
webContents.removeListener('did-navigate', registration.cleanup);
webContents.removeListener('did-start-navigation', registration.didStartNavigation);
this.grantCleanupByWebContents.delete(webContentsId);
}
private releaseGrantCleanupIfUnused(webContentsId: number): void {
for (const grant of this.grants.values()) {
if (grant.webContentsId === webContentsId) {
return;
}
}
this.unregisterGrantCleanup(webContentsId);
}
/**
* Consent dialog for a verified built-in plugin (name/version read from disk).
* Returns null when the id does not resolve to a cleanly-verified built-in
* nodeExecution manifest (no on-disk match, id mismatch, missing permission, or
* unreadable/invalid manifest), so the caller falls back to the unverified-uploaded
* dialog — a partial or colliding match must never *upgrade* trust to the built-in
* dialog.
*/
private describeVerifiedBuiltInDialog(
pluginId: string,
): Electron.MessageBoxOptions | null {
let manifest: PluginManifest;
try {
manifest = this.getVerifiedBuiltInNodeExecutionManifest(pluginId);
} catch {
return null;
}
return {
...NODE_CONSENT_DIALOG_BASE,
title: 'Allow plugin Node.js execution?',
message: `Allow "${manifest.name}" to run Node.js scripts?`,
detail: [
`Plugin ID: ${pluginId}`,
`Version: ${manifest.version}`,
'',
'This permission is valid for the current app session. Node.js execution can access local files and desktop APIs. Only allow plugins you trust.',
].join('\n'),
};
}
/**
* Consent dialog for an uploaded (community) plugin. The app cannot verify an
* uploaded plugin's identity, so the dialog anchors on the validated id and marks
* the renderer-supplied name/version as self-declared/unverified. Default = Deny.
*/
private describeUnverifiedUploadedDialog(
pluginId: string,
displayInfo?: NodeExecutionGrantDisplayInfo,
): Electron.MessageBoxOptions {
const name = sanitizeDialogString(displayInfo?.name, 80) || '(unnamed)';
const version = sanitizeDialogString(displayInfo?.version, 32) || '(unknown)';
return {
...NODE_CONSENT_DIALOG_BASE,
title: 'Allow this plugin to run code on your machine?',
message: `Plugin "${pluginId}" wants to run Node.js code`,
detail: [
`Plugin ID: ${pluginId}`,
`Name (self-declared, unverified): ${name}`,
`Version (self-declared): ${version}`,
'',
'This is a third-party plugin. Super Productivity cannot verify its identity and cannot sandbox it.',
'If you allow it, the plugin can run any program with full access to your files and system for this app session.',
'',
'Only allow this if you trust the source of this plugin.',
].join('\n'),
};
}
private getVerifiedBuiltInNodeExecutionManifest(pluginId: string): PluginManifest {
if (typeof pluginId !== 'string' || !pluginId) {
throw new Error('Invalid pluginId');
}
if (!BUILT_IN_PLUGIN_ID_RE.test(pluginId)) {
throw new Error('Invalid pluginId');
}
const manifestPath = this.getBuiltInManifestPath(pluginId);
if (!manifestPath) {
throw new Error('Plugin is not a verified built-in plugin');
}
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as PluginManifest;
if (!manifest || manifest.id !== pluginId) {
throw new Error('Verified plugin manifest does not match requested plugin');
}
if (typeof manifest.name !== 'string' || !manifest.name) {
throw new Error('Invalid plugin manifest name');
}
if (typeof manifest.version !== 'string' || !manifest.version) {
throw new Error('Invalid plugin manifest version');
}
if (!manifest.permissions?.includes('nodeExecution')) {
throw new Error('Plugin does not have nodeExecution permission');
}
return manifest;
}
private getBuiltInManifestPath(pluginId: string): string | null {
for (const baseDir of this.getBuiltInPluginBaseDirs()) {
const manifestPath = join(baseDir, pluginId, 'manifest.json');
if (existsSync(manifestPath)) {
return manifestPath;
}
}
return null;
}
private getBuiltInPluginBaseDirs(): string[] {
return Array.from(
new Set(
[
join(__dirname, '../.tmp/angular-dist/browser/assets/bundled-plugins'),
join(__dirname, '../src/assets/bundled-plugins'),
...(app.isPackaged
? []
: [
join(process.cwd(), '.tmp/angular-dist/browser/assets/bundled-plugins'),
join(process.cwd(), 'src/assets/bundled-plugins'),
]),
].map((p) => resolvePath(p)),
),
);
}
private async executeScript(
pluginId: string,
request: PluginNodeScriptRequest,
): Promise<PluginNodeScriptResult> {
const startTime = Date.now();
try {
// Validate request
this.validateScriptRequest(request);
// Try direct execution first (faster, safer)
if (this.canExecuteDirectly(request.script)) {
const result = await this.executeDirectly(request.script, request.args);
return {
success: true,
result,
executionTime: Date.now() - startTime,
};
}
// For complex scripts, use spawned process
const result = await this.executeViaSpawn(
request.script,
request.args,
request.timeout,
);
return {
success: true,
result,
executionTime: Date.now() - startTime,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error),
executionTime: Date.now() - startTime,
};
}
}
private validateScriptRequest(request: PluginNodeScriptRequest): void {
if (!request.script || typeof request.script !== 'string') {
throw new Error('Script must be a non-empty string');
}
if (request.script.length > 100000) {
throw new Error('Script too large (max 100KB)');
}
if (request.timeout !== undefined) {
if (typeof request.timeout !== 'number' || request.timeout < 0) {
throw new Error('Timeout must be a positive number');
}
if (request.timeout > MAX_TIMEOUT) {
throw new Error(`Timeout exceeds maximum allowed (${MAX_TIMEOUT}ms)`);
}
}
}
private canExecuteDirectly(script: string): boolean {
// Check if script only uses safe operations
const dangerousPatterns =
/require\s*\(\s*['"`](?!fs|path|os)[^'"]+['"`]\s*\)|child_process|exec|spawn|eval|Function|process\.exit/;
return !dangerousPatterns.test(script);
}
private async executeDirectly(script: string, args?: unknown[]): Promise<unknown> {
// Safe modules
const fs = await import('fs');
const path = await import('path');
const os = await import('os');
// Create sandboxed context
const sandbox = {
require: (module: string) => {
if (module === 'fs') return fs;
if (module === 'path') return path;
if (module === 'os') return os;
throw new Error(`Module '${module}' is not allowed`);
},
console: {
log: (...logArgs: unknown[]) => console.log('[Plugin]:', ...logArgs),
error: (...errorArgs: unknown[]) => console.error('[Plugin]:', ...errorArgs),
},
JSON,
args: args || [],
__result: undefined,
};
// Execute in VM with timeout
const context = vm.createContext(sandbox);
const script_wrapped = `
(async function() {
const result = await (async function() {
${script}
})();
__result = result;
})().catch(err => { throw err; });
`;
await vm.runInContext(script_wrapped, context, {
timeout: 5000, // 5 second timeout for direct execution
});
return sandbox.__result;
}
private async executeViaSpawn(
script: string,
args?: unknown[],
timeout?: number,
): Promise<unknown> {
return new Promise((resolve, reject) => {
const timeoutMs = Math.min(timeout || DEFAULT_TIMEOUT, MAX_TIMEOUT);
// Wrap script for security
const wrappedScript = `
'use strict';
(async function() {
const args = ${JSON.stringify(args || [])};
try {
const result = await (async function() {
${script}
})();
console.log(JSON.stringify({ __result: result }));
} catch (error) {
console.error(JSON.stringify({
__error: error.message || String(error)
}));
process.exit(1);
}
})();
`;
// Use electron's node or system node
const nodePath = process.execPath.includes('electron') ? process.execPath : 'node';
// Spawn process with script via -e flag (no temp files!)
const child = spawn(nodePath, ['--no-warnings', '-e', wrappedScript], {
env: {
NODE_ENV: 'production',
ELECTRON_RUN_AS_NODE: '1',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
let killed = false;
// Timeout
const timer = setTimeout(() => {
killed = true;
child.kill('SIGTERM');
// Force kill after a short delay if process doesn't terminate
setTimeout(() => {
if (!child.killed) {
child.kill('SIGKILL');
}
}, 1000);
reject(new Error(`Script execution timed out after ${timeoutMs}ms`));
}, timeoutMs);
child.stdout.on('data', (data) => {
stdout += data.toString();
});
child.stderr.on('data', (data) => {
stderr += data.toString();
});
child.on('error', (err) => {
clearTimeout(timer);
reject(new Error('Failed to execute script: ' + err.message));
});
child.on('close', (code) => {
clearTimeout(timer);
if (killed) return;
try {
if (stdout) {
const parsed = JSON.parse(stdout.trim());
if (parsed.__error) {
reject(new Error(parsed.__error));
} else {
resolve(parsed.__result);
}
} else if (code !== 0) {
reject(new Error(stderr || `Process exited with code ${code}`));
} else {
resolve(undefined);
}
} catch (e) {
reject(new Error(`Failed to parse output: ${e}`));
}
});
});
}
}
export const pluginNodeExecutor = new PluginNodeExecutor();