mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
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:
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user