feat(plugins): secure installer — manifest, discovery, safe extract/fetch/scan (M4)

Plugins placed on the /plugins volume are now discovered, validated and registered
as inactive, ready to activate.

- manifest.ts: strict trek-plugin.json validation (id/version/type, known
  permissions only, egress required with http:outbound, native modules rejected).
- discovery.ts: scans the volume on startup + on demand (POST /api/admin/plugins/
  rescan), upserts rows INACTIVE, refreshes settings-field descriptors, and never
  downgrades or wipes an already-installed plugin's status / grants / config.
  Invalid or native-carrying plugins are skipped and logged.
- Activation now grants the DECLARED permissions (the consent gate) and persists
  them before spawning.
- install/ utilities for the registry installer (M5), each independently tested:
  - safe-fetch: host allowlist (GitHub only) + private-IP refusal + manual
    redirect following + size cap + sha256 (constant-time compare).
  - safe-extract: zip/tar-slip-safe extraction with its own minimal tar.gz + zip
    readers; rejects traversal, absolute paths, symlinks, oversized/too-many
    entries, and unsupported formats.
  - native-scan: refuses .node / binding.gyp / prebuilds, never follows symlinks.
This commit is contained in:
mauriceboe
2026-07-03 17:26:46 +02:00
parent 037eb8a209
commit be07f29acd
13 changed files with 840 additions and 4 deletions
@@ -0,0 +1,79 @@
import fs from 'node:fs';
import path from 'node:path';
import type BetterSqlite3 from 'better-sqlite3';
import { pluginsCodeRoot, pluginCodeDir } from '../paths';
import { parseManifest, type PluginManifest } from './manifest';
import { scanForNativeBinaries } from './native-scan';
/**
* Discover plugins placed on the /plugins volume (#plugins, M4, "install from
* disk"). Reads each subdir's trek-plugin.json and upserts a registry row as
* INACTIVE — an existing plugin keeps its status / granted permissions / config,
* so re-discovery never silently re-activates or wipes settings. A plugin whose
* manifest is invalid or that ships native binaries is skipped (recorded to its
* error log if it already existed).
*/
export function discoverPlugins(db: BetterSqlite3.Database): { discovered: string[]; skipped: string[] } {
const root = pluginsCodeRoot();
const discovered: string[] = [];
const skipped: string[] = [];
if (!fs.existsSync(root)) return { discovered, skipped };
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
const dir = pluginCodeDir(entry.name);
const manifestPath = path.join(dir, 'trek-plugin.json');
if (!fs.existsSync(manifestPath)) continue;
try {
const manifest = parseManifest(JSON.parse(fs.readFileSync(manifestPath, 'utf8')));
if (manifest.id !== entry.name) throw new Error(`manifest id "${manifest.id}" != directory "${entry.name}"`);
if (scanForNativeBinaries(dir).length) throw new Error('directory contains native binaries');
upsert(db, manifest);
discovered.push(manifest.id);
} catch (e) {
skipped.push(entry.name);
const msg = e instanceof Error ? e.message : 'invalid plugin';
db.prepare('INSERT INTO plugin_error_log (plugin_id, level, message) VALUES (?, ?, ?)').run(entry.name, 'error', `discovery: ${msg}`);
}
}
return { discovered, skipped };
}
function upsert(db: BetterSqlite3.Database, m: PluginManifest): void {
const existing = db.prepare('SELECT id FROM plugins WHERE id = ?').get(m.id) as { id: string } | undefined;
if (existing) {
db.prepare(
`UPDATE plugins SET name = ?, description = ?, type = ?, icon = ?, version = ?, api_version = ?,
min_trek_version = ?, permissions = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
).run(m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, JSON.stringify(m.permissions), m.id);
} else {
db.prepare(
`INSERT INTO plugins (id, name, description, type, icon, version, api_version, min_trek_version, permissions, granted_permissions, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, '[]', 'inactive')`,
).run(m.id, m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, JSON.stringify(m.permissions));
}
// Refresh the settings-field descriptors from the manifest.
db.prepare('DELETE FROM plugin_settings_fields WHERE plugin_id = ?').run(m.id);
const insert = db.prepare(
`INSERT INTO plugin_settings_fields (plugin_id, field_key, label, input_type, placeholder, hint, required, secret, scope, options, oauth_config, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
);
m.settings.forEach((f, i) => {
insert.run(
m.id,
f.key,
f.label ?? f.key,
f.input_type ?? 'text',
f.placeholder ?? null,
f.hint ?? null,
f.required ? 1 : 0,
f.secret ? 1 : 0,
f.scope ?? 'instance',
f.options ? JSON.stringify(f.options) : null,
f.oauth ? JSON.stringify(f.oauth) : null,
i,
);
});
}
+118
View File
@@ -0,0 +1,118 @@
import { isKnownPermission } from '../protocol/envelope';
/**
* Parse + validate a plugin's trek-plugin.json (#plugins, M4). Kept deliberately
* strict: unknown permissions, missing required fields, or a declared native
* module all fail here, before a plugin is ever registered. (The published SDK's
* shared Zod schema will supersede this in M6; the checks stay identical.)
*/
export interface ManifestSettingField {
key: string;
label?: string;
input_type?: string;
placeholder?: string;
hint?: string;
required?: boolean;
secret?: boolean;
scope?: 'instance' | 'user';
options?: Array<{ value: string; label: string }>;
oauth?: { initPath?: string; callbackPath?: string };
}
export interface PluginManifest {
id: string;
name: string;
version: string;
apiVersion: number;
author?: string;
description?: string;
homepage?: string;
icon?: string;
type: 'integration' | 'page' | 'widget';
trek?: string;
minTrekVersion?: string;
nativeModules: boolean;
permissions: string[];
egress: string[];
settings: ManifestSettingField[];
}
const ID_RE = /^[a-z][a-z0-9-]{2,39}$/;
const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
const TYPES = new Set(['integration', 'page', 'widget']);
export class ManifestError extends Error {}
export function parseManifest(raw: unknown): PluginManifest {
if (!raw || typeof raw !== 'object') throw new ManifestError('manifest is not an object');
const m = raw as Record<string, unknown>;
const id = str(m.id, 'id');
if (!ID_RE.test(id)) throw new ManifestError(`invalid id "${id}" (lowercase slug, 3–40 chars)`);
const version = str(m.version, 'version');
if (!SEMVER_RE.test(version)) throw new ManifestError(`invalid version "${version}"`);
const type = str(m.type, 'type');
if (!TYPES.has(type)) throw new ManifestError(`invalid type "${type}"`);
if (m.nativeModules === true) throw new ManifestError('native modules are not allowed');
const permissions = arr(m.permissions).map(String);
const unknown = permissions.filter((p) => !isKnownPermission(p));
if (unknown.length) throw new ManifestError(`unknown permission(s): ${unknown.join(', ')}`);
const egress = arr(m.egress).map(String);
if (permissions.some((p) => p === 'http:outbound' || p.startsWith('http:outbound:')) && egress.length === 0) {
throw new ManifestError('http:outbound declared but egress[] is empty');
}
if (egress.includes('*')) throw new ManifestError('egress[] must not contain a bare "*"');
const trek = optStr(m.trek);
return {
id,
name: str(m.name, 'name'),
version,
apiVersion: typeof m.apiVersion === 'number' ? m.apiVersion : 1,
author: optStr(m.author),
description: optStr(m.description),
homepage: optStr(m.homepage),
icon: optStr(m.icon) ?? 'Blocks',
type: type as PluginManifest['type'],
trek,
minTrekVersion: trek ? (trek.match(/(\d+\.\d+\.\d+)/)?.[1] ?? undefined) : undefined,
nativeModules: false,
permissions,
egress,
settings: parseSettings(m.settings),
};
}
function parseSettings(raw: unknown): ManifestSettingField[] {
if (!Array.isArray(raw)) return [];
return raw
.filter((s): s is Record<string, unknown> => !!s && typeof s === 'object')
.map((s): ManifestSettingField => ({
key: String(s.key ?? ''),
label: optStr(s.label),
input_type: optStr(s.input_type) ?? 'text',
placeholder: optStr(s.placeholder),
hint: optStr(s.hint),
required: !!s.required,
secret: !!s.secret,
scope: s.scope === 'user' ? 'user' : 'instance',
options: Array.isArray(s.options) ? (s.options as Array<{ value: string; label: string }>) : undefined,
oauth: s.oauth && typeof s.oauth === 'object' ? (s.oauth as { initPath?: string; callbackPath?: string }) : undefined,
}))
.filter((s) => s.key);
}
function str(v: unknown, name: string): string {
if (typeof v !== 'string' || !v) throw new ManifestError(`missing/invalid "${name}"`);
return v;
}
function optStr(v: unknown): string | undefined {
return typeof v === 'string' && v ? v : undefined;
}
function arr(v: unknown): unknown[] {
return Array.isArray(v) ? v : [];
}
@@ -0,0 +1,37 @@
import fs from 'node:fs';
import path from 'node:path';
/**
* Scan a plugin directory for native binaries / build artifacts (#plugins, M4).
* Native modules (.node) are forbidden in v1 — a native addon in a fork() child
* is arbitrary native code at the plugin uid, which would defeat any future
* sandbox. Returns the offending relative paths (empty = clean).
*/
const OFFENDERS = /\.node$|(^|[\\/])binding\.gyp$|(^|[\\/])prebuilds?[\\/]/i;
const MAX_ENTRIES = 20_000;
export function scanForNativeBinaries(dir: string): string[] {
const hits: string[] = [];
let seen = 0;
const walk = (d: string, rel: string) => {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(d, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
if (++seen > MAX_ENTRIES) return;
const childRel = rel ? `${rel}/${e.name}` : e.name;
if (e.isSymbolicLink()) continue; // never follow symlinks
if (e.isDirectory()) {
if (OFFENDERS.test(childRel + '/')) hits.push(childRel);
else walk(path.join(d, e.name), childRel);
} else if (OFFENDERS.test(childRel)) {
hits.push(childRel);
}
}
};
walk(dir, '');
return hits;
}
@@ -0,0 +1,137 @@
import fs from 'node:fs';
import path from 'node:path';
import zlib from 'node:zlib';
/**
* Zip/tar-slip-safe extraction (#plugins, M4). Extracts an artifact into a fresh
* directory, refusing anything that would escape it: absolute paths, ".."
* segments, symlinks/hardlinks, non-regular entries, and oversized / too-many /
* high-ratio (zip-bomb) archives. The containment check is the same idiom the
* platform routes use for uploads.
*
* v1 supports gzip'd tar (codeload archives) and stored/deflated ZIP (release
* assets) via a minimal reader — no third-party unpacker in the trust path.
*/
export interface ExtractLimits {
maxTotalBytes?: number; // uncompressed
maxEntries?: number;
maxFileBytes?: number;
}
const DEFAULTS: Required<ExtractLimits> = {
maxTotalBytes: 50 * 1024 * 1024,
maxEntries: 4000,
maxFileBytes: 25 * 1024 * 1024,
};
export class ExtractError extends Error {}
/** Assert a member path stays within dest; return the safe absolute path. */
export function safeJoin(dest: string, name: string): string {
const norm = name.replace(/\\/g, '/');
if (norm.startsWith('/') || /^[a-zA-Z]:/.test(norm)) throw new ExtractError(`absolute path rejected: ${name}`);
if (norm.split('/').some((seg) => seg === '..')) throw new ExtractError(`path traversal rejected: ${name}`);
const resolved = path.resolve(dest, norm);
const root = path.resolve(dest);
if (resolved !== root && !resolved.startsWith(root + path.sep)) throw new ExtractError(`escapes destination: ${name}`);
return resolved;
}
interface Member {
name: string;
type: 'file' | 'dir' | 'other';
data?: Buffer;
}
export function extractArchive(buf: Buffer, dest: string, limits: ExtractLimits = {}): number {
const lim = { ...DEFAULTS, ...limits };
const isGzip = buf[0] === 0x1f && buf[1] === 0x8b;
const isZip = buf[0] === 0x50 && buf[1] === 0x4b;
const members = isGzip ? readTarGz(buf) : isZip ? readZip(buf) : null;
if (!members) throw new ExtractError('unsupported archive format (expected .tar.gz or .zip)');
let total = 0;
let count = 0;
fs.mkdirSync(dest, { recursive: true });
for (const m of members) {
if (++count > lim.maxEntries) throw new ExtractError('too many entries');
if (m.type === 'other') throw new ExtractError(`non-regular entry rejected: ${m.name}`); // symlink/device/etc
const target = safeJoin(dest, m.name);
if (m.type === 'dir') {
fs.mkdirSync(target, { recursive: true });
continue;
}
const size = m.data?.length ?? 0;
if (size > lim.maxFileBytes) throw new ExtractError(`file too large: ${m.name}`);
total += size;
if (total > lim.maxTotalBytes) throw new ExtractError('archive exceeds size limit');
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, m.data ?? Buffer.alloc(0));
}
return total;
}
// ── minimal tar.gz reader ────────────────────────────────────────────────────
function readTarGz(buf: Buffer): Member[] {
const tar = zlib.gunzipSync(buf, { maxOutputLength: DEFAULTS.maxTotalBytes + 1024 * 1024 });
const members: Member[] = [];
let off = 0;
while (off + 512 <= tar.length) {
const block = tar.subarray(off, off + 512);
if (block.every((b) => b === 0)) break;
let name = block.subarray(0, 100).toString('utf8').replace(/\0.*$/, '');
const prefix = block.subarray(345, 500).toString('utf8').replace(/\0.*$/, '');
if (prefix) name = `${prefix}/${name}`;
const size = parseInt(block.subarray(124, 136).toString('utf8').replace(/\0.*$/, '').trim() || '0', 8);
const typeflag = String.fromCharCode(block[156]);
off += 512;
const data = tar.subarray(off, off + size);
off += Math.ceil(size / 512) * 512;
if (!name || name.endsWith('/PaxHeader/') || typeflag === 'x' || typeflag === 'g') continue;
if (typeflag === '5') members.push({ name, type: 'dir' });
else if (typeflag === '0' || typeflag === '\0' || typeflag === '') members.push({ name, type: 'file', data: Buffer.from(data) });
else members.push({ name, type: 'other' }); // links (1,2), devices, fifo
}
return members;
}
// ── minimal ZIP reader (central directory) ───────────────────────────────────
function readZip(buf: Buffer): Member[] {
// Find End Of Central Directory.
let eocd = -1;
for (let i = buf.length - 22; i >= 0 && i > buf.length - 22 - 65536; i--) {
if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
}
if (eocd < 0) throw new ExtractError('invalid zip (no EOCD)');
const count = buf.readUInt16LE(eocd + 10);
let p = buf.readUInt32LE(eocd + 16); // central dir offset
const members: Member[] = [];
for (let i = 0; i < count; i++) {
if (buf.readUInt32LE(p) !== 0x02014b50) throw new ExtractError('invalid zip (central header)');
const method = buf.readUInt16LE(p + 10);
const compSize = buf.readUInt32LE(p + 20);
const nameLen = buf.readUInt16LE(p + 28);
const extraLen = buf.readUInt16LE(p + 30);
const commentLen = buf.readUInt16LE(p + 32);
const extAttr = buf.readUInt32LE(p + 38); // unix mode in high 16 bits
const lho = buf.readUInt32LE(p + 42);
const name = buf.subarray(p + 46, p + 46 + nameLen).toString('utf8');
const unixMode = (extAttr >>> 16) & 0xffff;
const isSymlink = (unixMode & 0xf000) === 0xa000;
// local header: recompute data start
const lhNameLen = buf.readUInt16LE(lho + 26);
const lhExtraLen = buf.readUInt16LE(lho + 28);
const dataStart = lho + 30 + lhNameLen + lhExtraLen;
const comp = buf.subarray(dataStart, dataStart + compSize);
p += 46 + nameLen + extraLen + commentLen;
if (isSymlink) { members.push({ name, type: 'other' }); continue; }
if (name.endsWith('/')) { members.push({ name, type: 'dir' }); continue; }
let data: Buffer;
if (method === 0) data = Buffer.from(comp);
else if (method === 8) data = zlib.inflateRawSync(comp, { maxOutputLength: DEFAULTS.maxFileBytes + 1024 });
else throw new ExtractError(`unsupported zip compression method ${method}`);
members.push({ name, type: 'file', data });
}
return members;
}
@@ -0,0 +1,79 @@
import { createHash } from 'node:crypto';
import dns from 'node:dns/promises';
/**
* SSRF-hardened download for the plugin installer (#plugins, M4). The primary
* control is a host allowlist (only GitHub delivery hosts); as defense in depth
* we resolve each hop and refuse private/loopback/link-local IPs, and we follow
* redirects manually so a 3xx to an internal address can't slip through. Returns
* the bytes + their sha256; the caller verifies the hash against the registry
* pin before anything is written to a plugin location.
*/
const ALLOWED_HOSTS = new Set(['github.com', 'codeload.github.com', 'objects.githubusercontent.com']);
const MAX_REDIRECTS = 5;
const MAX_BYTES = 50 * 1024 * 1024;
export class DownloadError extends Error {}
export function isPrivateIp(ip: string): boolean {
const v4 = ip.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
if (v4) {
const [a, b] = [Number(v4[1]), Number(v4[2])];
return (
a === 10 ||
a === 127 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 169 && b === 254) ||
a === 0
);
}
const v6 = ip.toLowerCase();
return v6 === '::1' || v6.startsWith('fc') || v6.startsWith('fd') || v6.startsWith('fe80') || v6.startsWith('::ffff:127.');
}
async function assertSafeHost(urlStr: string): Promise<void> {
let url: URL;
try {
url = new URL(urlStr);
} catch {
throw new DownloadError(`invalid url: ${urlStr}`);
}
if (url.protocol !== 'https:') throw new DownloadError('only https downloads are allowed');
if (!ALLOWED_HOSTS.has(url.hostname)) throw new DownloadError(`host not allowlisted: ${url.hostname}`);
const addrs = await dns.lookup(url.hostname, { all: true }).catch(() => []);
if (addrs.length === 0) throw new DownloadError(`could not resolve ${url.hostname}`);
for (const a of addrs) {
if (isPrivateIp(a.address)) throw new DownloadError(`refusing private address for ${url.hostname}`);
}
}
export async function safeDownload(urlStr: string, maxBytes = MAX_BYTES): Promise<{ bytes: Buffer; sha256: string }> {
let current = urlStr;
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
await assertSafeHost(current);
const resp = await fetch(current, { redirect: 'manual', headers: { 'User-Agent': 'TREK-Server' } });
if (resp.status >= 300 && resp.status < 400) {
const loc = resp.headers.get('location');
if (!loc) throw new DownloadError('redirect without a location');
current = new URL(loc, current).toString();
continue;
}
if (!resp.ok) throw new DownloadError(`download failed: ${resp.status}`);
const declared = Number(resp.headers.get('content-length') || 0);
if (declared && declared > maxBytes) throw new DownloadError('artifact exceeds size limit');
const bytes = Buffer.from(await resp.arrayBuffer());
if (bytes.length > maxBytes) throw new DownloadError('artifact exceeds size limit');
return { bytes, sha256: createHash('sha256').update(bytes).digest('hex') };
}
throw new DownloadError('too many redirects');
}
/** Constant-time compare of two hex digests. */
export function sha256Matches(actual: string, expected: string): boolean {
if (actual.length !== expected.length) return false;
let diff = 0;
for (let i = 0; i < actual.length; i++) diff |= actual.charCodeAt(i) ^ expected.charCodeAt(i);
return diff === 0;
}
@@ -5,6 +5,7 @@ import { decrypt_api_key } from '../../services/apiKeyCrypto';
import { PluginSupervisor, type PluginRouteInfo } from './supervisor/plugin-supervisor';
import { createRealRpcHost, closePluginDataDb } from './host/create-rpc-host';
import { isKnownPermission } from './protocol/envelope';
import { discoverPlugins } from './install/discovery';
/**
* Owns the isolated-plugin runtime lifecycle inside NestJS (#plugins, M2).
@@ -41,6 +42,13 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
onModuleInit(): void {
if (!pluginsEnabled()) return;
// Discover plugins placed on the volume (registers new ones inactive), then
// boot the ones an admin had already activated.
try {
discoverPlugins(db);
} catch {
/* discovery must never block boot */
}
const active = db.prepare("SELECT id FROM plugins WHERE status = 'active'").all() as Array<{ id: string }>;
for (const { id } of active) {
this.activate(id).catch(() => {
@@ -49,6 +57,11 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
}
}
/** Re-scan the plugins volume on demand (admin action). */
rescan(): { discovered: string[]; skipped: string[] } {
return discoverPlugins(db);
}
async onModuleDestroy(): Promise<void> {
await this.supervisor.shutdownAll();
}
@@ -60,9 +73,12 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
| undefined;
if (!row) throw new Error(`plugin ${id} not found`);
const granted = new Set(parseArray(row.granted_permissions).filter(isKnownPermission));
// Activating IS the consent gate: grant the plugin's DECLARED permissions
// (the admin reviewed them on the consent screen), persist, then spawn.
const declared = parseArray(row.permissions).filter(isKnownPermission);
db.prepare('UPDATE plugins SET granted_permissions = ? WHERE id = ?').run(JSON.stringify(declared), id);
const config = decryptConfig(parseObject(row.config));
await this.supervisor.activate(id, granted, config);
await this.supervisor.activate(id, new Set(declared), config);
}
async deactivate(id: string): Promise<void> {
@@ -54,4 +54,11 @@ export class PluginsController {
await this.runtime.deactivate(id);
return { status: 'inactive' };
}
@Post('rescan')
@HttpCode(200)
rescan() {
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
return this.runtime.rescan();
}
}