diff --git a/server/src/nest/plugins/install/discovery.ts b/server/src/nest/plugins/install/discovery.ts new file mode 100644 index 000000000..d8e8d5160 --- /dev/null +++ b/server/src/nest/plugins/install/discovery.ts @@ -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, + ); + }); +} diff --git a/server/src/nest/plugins/install/manifest.ts b/server/src/nest/plugins/install/manifest.ts new file mode 100644 index 000000000..96778fa2f --- /dev/null +++ b/server/src/nest/plugins/install/manifest.ts @@ -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; + + 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 => !!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 : []; +} diff --git a/server/src/nest/plugins/install/native-scan.ts b/server/src/nest/plugins/install/native-scan.ts new file mode 100644 index 000000000..aaa24cd61 --- /dev/null +++ b/server/src/nest/plugins/install/native-scan.ts @@ -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; +} diff --git a/server/src/nest/plugins/install/safe-extract.ts b/server/src/nest/plugins/install/safe-extract.ts new file mode 100644 index 000000000..f976a7be4 --- /dev/null +++ b/server/src/nest/plugins/install/safe-extract.ts @@ -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 = { + 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; +} diff --git a/server/src/nest/plugins/install/safe-fetch.ts b/server/src/nest/plugins/install/safe-fetch.ts new file mode 100644 index 000000000..374358eaa --- /dev/null +++ b/server/src/nest/plugins/install/safe-fetch.ts @@ -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 { + 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; +} diff --git a/server/src/nest/plugins/plugin-runtime.service.ts b/server/src/nest/plugins/plugin-runtime.service.ts index 022677380..d954ce10b 100644 --- a/server/src/nest/plugins/plugin-runtime.service.ts +++ b/server/src/nest/plugins/plugin-runtime.service.ts @@ -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 { 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 { diff --git a/server/src/nest/plugins/plugins.controller.ts b/server/src/nest/plugins/plugins.controller.ts index 718e3837b..b5372be5e 100644 --- a/server/src/nest/plugins/plugins.controller.ts +++ b/server/src/nest/plugins/plugins.controller.ts @@ -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(); + } } diff --git a/server/tests/integration/plugins/plugin-runtime.test.ts b/server/tests/integration/plugins/plugin-runtime.test.ts index abdf3f843..001b7034a 100644 --- a/server/tests/integration/plugins/plugin-runtime.test.ts +++ b/server/tests/integration/plugins/plugin-runtime.test.ts @@ -51,7 +51,7 @@ beforeAll(() => { };`, ); - testDb.prepare("INSERT INTO plugins (id, status, granted_permissions, config) VALUES ('counter','active','[\"db:own\"]','{}')").run(); + testDb.prepare("INSERT INTO plugins (id, status, permissions, config) VALUES ('counter','active','[\"db:own\"]','{}')").run(); runtime = new PluginRuntimeService(); }); @@ -124,7 +124,7 @@ describe('PluginRuntimeService (M2 end-to-end)', () => { it('tolerates malformed granted_permissions / config JSON on activate', async () => { fs.mkdirSync(path.join(codeRoot, 'messy', 'server'), { recursive: true }); fs.writeFileSync(path.join(codeRoot, 'messy', 'server', 'index.js'), 'module.exports = { async onLoad() {} };'); - testDb.prepare("INSERT INTO plugins (id, status, granted_permissions, config) VALUES ('messy','inactive','not-json','not-json')").run(); + testDb.prepare("INSERT INTO plugins (id, status, permissions, config) VALUES ('messy','inactive','not-json','not-json')").run(); const rt = new PluginRuntimeService(); await rt.activate('messy'); // must not throw despite the garbage JSON expect(rt.isActive('messy')).toBe(true); diff --git a/server/tests/unit/plugins/discovery.test.ts b/server/tests/unit/plugins/discovery.test.ts new file mode 100644 index 000000000..8a933b50b --- /dev/null +++ b/server/tests/unit/plugins/discovery.test.ts @@ -0,0 +1,90 @@ +/** + * Plugin discovery (#plugins, M4, install-from-disk): scans the volume, upserts + * rows as inactive, refreshes settings fields, keeps an existing plugin's status, + * and skips invalid or native-carrying plugins (logging the reason). + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Database from 'better-sqlite3'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { discoverPlugins } from '../../../src/nest/plugins/install/discovery'; + +let db: Database.Database; +let codeRoot: string; + +function writePlugin(id: string, manifest: Record, extra?: () => void) { + const dir = path.join(codeRoot, id, 'server'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(codeRoot, id, 'trek-plugin.json'), JSON.stringify({ id, name: id, version: '1.0.0', type: 'integration', ...manifest })); + fs.writeFileSync(path.join(dir, 'index.js'), 'module.exports={}'); + extra?.() +} + +beforeEach(() => { + codeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'disc-')); + process.env.TREK_PLUGINS_DIR = codeRoot; + db = new Database(':memory:'); + db.exec(` + CREATE TABLE plugins (id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT, + api_version INTEGER, min_trek_version TEXT, permissions TEXT, granted_permissions TEXT, status TEXT, config TEXT, updated_at TEXT); + CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, label TEXT, input_type TEXT, placeholder TEXT, hint TEXT, + required INTEGER, secret INTEGER, scope TEXT, options TEXT, oauth_config TEXT, sort_order INTEGER); + CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);`); +}); +afterEach(() => { + delete process.env.TREK_PLUGINS_DIR; + db.close(); + fs.rmSync(codeRoot, { recursive: true, force: true }); +}); + +describe('discoverPlugins', () => { + it('registers a new plugin inactive with its settings fields', () => { + writePlugin('flight-tracker', { + name: 'Flight', + type: 'widget', + permissions: ['db:own'], + settings: [{ key: 'api_key', input_type: 'password', scope: 'instance', secret: true }], + }); + const res = discoverPlugins(db); + expect(res.discovered).toEqual(['flight-tracker']); + + const row = db.prepare("SELECT status, type, permissions FROM plugins WHERE id='flight-tracker'").get() as { status: string; type: string; permissions: string }; + expect(row.status).toBe('inactive'); + expect(row.type).toBe('widget'); + expect(JSON.parse(row.permissions)).toEqual(['db:own']); + + const field = db.prepare("SELECT field_key, secret FROM plugin_settings_fields WHERE plugin_id='flight-tracker'").get() as { field_key: string; secret: number }; + expect(field).toMatchObject({ field_key: 'api_key', secret: 1 }); + }); + + it('keeps an existing plugin status + granted permissions on re-discovery', () => { + db.prepare("INSERT INTO plugins (id, name, type, status, granted_permissions) VALUES ('keep','Keep','page','active','[\"db:own\"]')").run(); + writePlugin('keep', { name: 'Keep v2', type: 'page', version: '2.0.0' }); + discoverPlugins(db); + const row = db.prepare("SELECT status, version, granted_permissions FROM plugins WHERE id='keep'").get() as { status: string; version: string; granted_permissions: string }; + expect(row.status).toBe('active'); // not downgraded + expect(row.version).toBe('2.0.0'); // metadata refreshed + expect(JSON.parse(row.granted_permissions)).toEqual(['db:own']); // grants preserved + }); + + it('skips an invalid manifest and logs the reason', () => { + writePlugin('bad', { type: 'not-a-type' }); + const res = discoverPlugins(db); + expect(res.skipped).toEqual(['bad']); + expect(db.prepare("SELECT COUNT(*) c FROM plugins WHERE id='bad'").get()).toMatchObject({ c: 0 }); + expect((db.prepare("SELECT message FROM plugin_error_log WHERE plugin_id='bad'").get() as { message: string }).message).toContain('discovery'); + }); + + it('skips a plugin that ships native binaries', () => { + writePlugin('native', { type: 'integration' }, () => { + fs.writeFileSync(path.join(codeRoot, 'native', 'server', 'addon.node'), '\0'); + }); + expect(discoverPlugins(db).skipped).toEqual(['native']); + }); + + it('is a no-op when the plugins dir is absent', () => { + process.env.TREK_PLUGINS_DIR = path.join(codeRoot, 'does-not-exist'); + expect(discoverPlugins(db)).toEqual({ discovered: [], skipped: [] }); + }); +}); diff --git a/server/tests/unit/plugins/manifest.test.ts b/server/tests/unit/plugins/manifest.test.ts new file mode 100644 index 000000000..b2432fa1d --- /dev/null +++ b/server/tests/unit/plugins/manifest.test.ts @@ -0,0 +1,49 @@ +/** + * trek-plugin.json validation (#plugins, M4). Rejects invalid ids/versions/types, + * unknown permissions, native modules, and http:outbound without egress. + */ +import { describe, it, expect } from 'vitest'; +import { parseManifest, ManifestError } from '../../../src/nest/plugins/install/manifest'; + +const base = { id: 'flight-tracker', name: 'Flight', version: '1.2.0', type: 'widget', apiVersion: 1 }; + +describe('parseManifest', () => { + it('parses a valid manifest with defaults', () => { + const m = parseManifest({ ...base }); + expect(m.id).toBe('flight-tracker'); + expect(m.icon).toBe('Blocks'); + expect(m.nativeModules).toBe(false); + expect(m.permissions).toEqual([]); + }); + + it('derives minTrekVersion from the trek range', () => { + expect(parseManifest({ ...base, trek: '>=3.2.0 <4.0.0' }).minTrekVersion).toBe('3.2.0'); + }); + + it('keeps known permissions + parses settings', () => { + const m = parseManifest({ + ...base, + permissions: ['db:own', 'http:outbound:api.x.com'], + egress: ['api.x.com'], + settings: [{ key: 'api_key', input_type: 'password', scope: 'instance', secret: true }, { bad: 1 }], + }); + expect(m.permissions).toContain('db:own'); + expect(m.settings).toHaveLength(1); + expect(m.settings[0]).toMatchObject({ key: 'api_key', secret: true, scope: 'instance' }); + }); + + it.each([ + ['Bad-Id', { ...base, id: 'Bad-Id' }, /invalid id/], + ['bad version', { ...base, version: '1.x' }, /invalid version/], + ['bad type', { ...base, type: 'thing' }, /invalid type/], + ['native', { ...base, nativeModules: true }, /native modules/], + ['unknown perm', { ...base, permissions: ['fs:read'] }, /unknown permission/], + ['outbound no egress', { ...base, permissions: ['http:outbound'] }, /egress\[\] is empty/], + ['wildcard egress', { ...base, permissions: ['http:outbound'], egress: ['*'] }, /bare "\*"/], + ['not an object', 'nope', /not an object/], + ['missing name', { id: 'x-plugin', version: '1.0.0', type: 'page' }, /missing\/invalid "name"/], + ])('rejects: %s', (_label, input, re) => { + expect(() => parseManifest(input)).toThrow(ManifestError); + expect(() => parseManifest(input)).toThrow(re as RegExp); + }); +}); diff --git a/server/tests/unit/plugins/native-scan.test.ts b/server/tests/unit/plugins/native-scan.test.ts new file mode 100644 index 000000000..d65888018 --- /dev/null +++ b/server/tests/unit/plugins/native-scan.test.ts @@ -0,0 +1,32 @@ +/** + * Native-binary scan (#plugins, M4): .node / binding.gyp / prebuilds are + * forbidden; symlinks are never followed. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { scanForNativeBinaries } from '../../../src/nest/plugins/install/native-scan'; + +let dir: string; +beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nscan-')); }); +afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); }); + +describe('scanForNativeBinaries', () => { + it('is clean for a plain JS plugin', () => { + fs.mkdirSync(path.join(dir, 'server'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'server', 'index.js'), 'module.exports = {}'); + expect(scanForNativeBinaries(dir)).toEqual([]); + }); + + it('flags .node binaries, binding.gyp and prebuilds/', () => { + fs.mkdirSync(path.join(dir, 'node_modules', 'sharp', 'prebuilds'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'node_modules', 'sharp', 'sharp.node'), '\0'); + fs.writeFileSync(path.join(dir, 'node_modules', 'sharp', 'binding.gyp'), '{}'); + fs.writeFileSync(path.join(dir, 'node_modules', 'sharp', 'prebuilds', 'x.txt'), 'x'); + const hits = scanForNativeBinaries(dir); + expect(hits.some((h) => h.endsWith('sharp.node'))).toBe(true); + expect(hits.some((h) => h.endsWith('binding.gyp'))).toBe(true); + expect(hits.some((h) => h.includes('prebuilds'))).toBe(true); + }); +}); diff --git a/server/tests/unit/plugins/safe-extract.test.ts b/server/tests/unit/plugins/safe-extract.test.ts new file mode 100644 index 000000000..02b355a48 --- /dev/null +++ b/server/tests/unit/plugins/safe-extract.test.ts @@ -0,0 +1,126 @@ +/** + * Zip/tar-slip-safe extraction (#plugins, M4). Verifies containment (safeJoin), + * happy-path extraction for tar.gz + zip, and that symlinks, traversal and + * oversized archives are refused. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import zlib from 'node:zlib'; +import { safeJoin, extractArchive, ExtractError } from '../../../src/nest/plugins/install/safe-extract'; + +// ── tiny archive builders ──────────────────────────────────────────────────── +function tarHeader(name: string, size: number, typeflag = '0', linkname = ''): Buffer { + const h = Buffer.alloc(512, 0); + h.write(name, 0); + h.write('0000644', 100); + h.write('0000000', 108); + h.write('0000000', 116); + h.write(size.toString(8).padStart(11, '0'), 124); + h.write('00000000000', 136); + h.write(' ', 148); // checksum placeholder = spaces + h.write(typeflag, 156); + if (linkname) h.write(linkname, 157); + h.write('ustar\0', 257); + h.write('00', 263); + let sum = 0; + for (let i = 0; i < 512; i++) sum += h[i]; + h.write(sum.toString(8).padStart(6, '0') + '\0 ', 148); + return h; +} +function makeTarGz(entries: Array<{ name: string; data?: string; type?: string; link?: string }>): Buffer { + const parts: Buffer[] = []; + for (const e of entries) { + const body = Buffer.from(e.data ?? ''); + parts.push(tarHeader(e.name, e.type === '5' || e.type === '2' ? 0 : body.length, e.type ?? '0', e.link ?? '')); + if (e.type !== '5' && e.type !== '2') { + parts.push(body); + const pad = (512 - (body.length % 512)) % 512; + if (pad) parts.push(Buffer.alloc(pad, 0)); + } + } + parts.push(Buffer.alloc(1024, 0)); + return zlib.gzipSync(Buffer.concat(parts)); +} +function makeStoredZip(name: string, data: string): Buffer { + const nameB = Buffer.from(name); + const dataB = Buffer.from(data); + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(0, 8); // method 0 (stored) + local.writeUInt32LE(dataB.length, 18); + local.writeUInt32LE(dataB.length, 22); + local.writeUInt16LE(nameB.length, 26); + const localRec = Buffer.concat([local, nameB, dataB]); + + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(0, 10); + central.writeUInt32LE(dataB.length, 20); + central.writeUInt32LE(dataB.length, 24); + central.writeUInt16LE(nameB.length, 28); + central.writeUInt32LE(0, 42); // local header offset + const centralRec = Buffer.concat([central, nameB]); + + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(0x06054b50, 0); + eocd.writeUInt16LE(1, 8); + eocd.writeUInt16LE(1, 10); + eocd.writeUInt32LE(centralRec.length, 12); + eocd.writeUInt32LE(localRec.length, 16); + return Buffer.concat([localRec, centralRec, eocd]); +} + +let dest: string; +beforeEach(() => { dest = fs.mkdtempSync(path.join(os.tmpdir(), 'xtract-')); }); +afterEach(() => { fs.rmSync(dest, { recursive: true, force: true }); }); + +describe('safeJoin', () => { + it('allows a normal nested path', () => { + expect(safeJoin(dest, 'server/index.js')).toBe(path.resolve(dest, 'server/index.js')); + }); + it.each([['../escape', /traversal/], ['/etc/passwd', /absolute/], ['a/../../b', /traversal/]])( + 'rejects %s', + (name, re) => { + expect(() => safeJoin(dest, name)).toThrow(re as RegExp); + }, + ); +}); + +describe('extractArchive', () => { + it('extracts a tar.gz (file + nested dir)', () => { + const gz = makeTarGz([ + { name: 'trek-plugin.json', data: '{"id":"x"}' }, + { name: 'server/', type: '5' }, + { name: 'server/index.js', data: 'module.exports={}' }, + ]); + extractArchive(gz, dest); + expect(fs.readFileSync(path.join(dest, 'trek-plugin.json'), 'utf8')).toBe('{"id":"x"}'); + expect(fs.readFileSync(path.join(dest, 'server', 'index.js'), 'utf8')).toBe('module.exports={}'); + }); + + it('extracts a stored zip', () => { + extractArchive(makeStoredZip('a.txt', 'hello'), dest); + expect(fs.readFileSync(path.join(dest, 'a.txt'), 'utf8')).toBe('hello'); + }); + + it('refuses a symlink entry', () => { + const gz = makeTarGz([{ name: 'link', type: '2', link: '/etc/passwd' }]); + expect(() => extractArchive(gz, dest)).toThrow(/non-regular/); + }); + + it('refuses path traversal', () => { + const gz = makeTarGz([{ name: '../evil.js', data: 'x' }]); + expect(() => extractArchive(gz, dest)).toThrow(ExtractError); + }); + + it('enforces the total-size limit', () => { + const gz = makeTarGz([{ name: 'big.bin', data: 'x'.repeat(2000) }]); + expect(() => extractArchive(gz, dest, { maxTotalBytes: 1000 })).toThrow(/size limit/); + }); + + it('rejects an unsupported format', () => { + expect(() => extractArchive(Buffer.from('not an archive'), dest)).toThrow(/unsupported/); + }); +}); diff --git a/server/tests/unit/plugins/safe-fetch.test.ts b/server/tests/unit/plugins/safe-fetch.test.ts new file mode 100644 index 000000000..4c958d3b2 --- /dev/null +++ b/server/tests/unit/plugins/safe-fetch.test.ts @@ -0,0 +1,66 @@ +/** + * SSRF-hardened installer download (#plugins, M4): host allowlist, private-IP + * refusal, manual redirect following, size cap, sha256. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const { lookup } = vi.hoisted(() => ({ lookup: vi.fn(async () => [{ address: '140.82.121.3', family: 4 }]) })); +vi.mock('node:dns/promises', () => ({ default: { lookup } })); + +import { isPrivateIp, sha256Matches, safeDownload, DownloadError } from '../../../src/nest/plugins/install/safe-fetch'; + +beforeEach(() => { + lookup.mockResolvedValue([{ address: '140.82.121.3', family: 4 }]); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe('isPrivateIp', () => { + it.each(['10.0.0.1', '127.0.0.1', '172.16.5.5', '192.168.1.1', '169.254.1.1', '::1', 'fd00::1', 'fe80::1'])( + 'private: %s', + (ip) => expect(isPrivateIp(ip)).toBe(true), + ); + it.each(['140.82.121.3', '8.8.8.8', '2606:2800::1'])('public: %s', (ip) => expect(isPrivateIp(ip)).toBe(false)); +}); + +describe('sha256Matches', () => { + it('constant-time equals', () => { + expect(sha256Matches('abc123', 'abc123')).toBe(true); + expect(sha256Matches('abc123', 'abc124')).toBe(false); + expect(sha256Matches('abc', 'abcd')).toBe(false); + }); +}); + +describe('safeDownload', () => { + it('rejects a non-allowlisted host before any fetch', async () => { + await expect(safeDownload('https://evil.example/x.zip')).rejects.toThrow(/not allowlisted/); + }); + + it('rejects non-https', async () => { + await expect(safeDownload('http://github.com/x.zip')).rejects.toThrow(/https/); + }); + + it('refuses when the host resolves to a private address', async () => { + lookup.mockResolvedValue([{ address: '127.0.0.1', family: 4 }]); + await expect(safeDownload('https://github.com/x.zip')).rejects.toThrow(/private address/); + }); + + it('downloads and hashes, following an allowlisted redirect', async () => { + const bytes = Buffer.from('plugin-bytes'); + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ status: 302, headers: new Headers({ location: 'https://codeload.github.com/x.tar.gz' }) }) + .mockResolvedValueOnce({ status: 200, ok: true, headers: new Headers(), arrayBuffer: async () => bytes }); + vi.stubGlobal('fetch', fetchMock); + + const out = await safeDownload('https://github.com/x'); + expect(out.bytes.toString()).toBe('plugin-bytes'); + expect(out.sha256).toMatch(/^[0-9a-f]{64}$/); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('enforces the size cap', async () => { + const fetchMock = vi.fn().mockResolvedValue({ status: 200, ok: true, headers: new Headers(), arrayBuffer: async () => Buffer.alloc(2000) }); + vi.stubGlobal('fetch', fetchMock); + await expect(safeDownload('https://github.com/x', 1000)).rejects.toThrow(DownloadError); + }); +});