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,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<string, unknown>, 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: [] });
});
});
@@ -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);
});
});
@@ -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);
});
});
@@ -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/);
});
});
@@ -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);
});
});