diff --git a/server/src/nest/plugins/plugins.controller.ts b/server/src/nest/plugins/plugins.controller.ts index b5372be5e..d18200934 100644 --- a/server/src/nest/plugins/plugins.controller.ts +++ b/server/src/nest/plugins/plugins.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, Get, HttpCode, HttpException, Param, Post, Put, UseGuards } from '@nestjs/common'; import { PluginsService } from './plugins.service'; import { PluginRuntimeService } from './plugin-runtime.service'; +import { PluginRegistryService } from './registry/registry.service'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { AdminGuard } from '../auth/admin.guard'; import { pluginsEnabled } from './kill-switch'; @@ -19,6 +20,7 @@ export class PluginsController { constructor( private readonly plugins: PluginsService, private readonly runtime: PluginRuntimeService, + private readonly registry: PluginRegistryService, ) {} @Get() @@ -26,6 +28,23 @@ export class PluginsController { return this.plugins.list(); } + @Get('registry') + browse() { + return this.registry.browse(); + } + + @Post('install') + @HttpCode(200) + async install(@Body() body: { id?: string; version?: string }) { + if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503); + if (!body?.id) throw new HttpException({ error: 'id is required' }, 400); + try { + return await this.registry.install(body.id, body.version); + } catch (e) { + throw new HttpException({ error: e instanceof Error ? e.message : 'install failed' }, 400); + } + } + @Get(':id/config') getConfig(@Param('id') id: string) { return { config: this.plugins.getInstanceConfig(id) }; diff --git a/server/src/nest/plugins/plugins.module.ts b/server/src/nest/plugins/plugins.module.ts index 777b3fa19..c517778ff 100644 --- a/server/src/nest/plugins/plugins.module.ts +++ b/server/src/nest/plugins/plugins.module.ts @@ -5,6 +5,7 @@ import { PluginsProxyController } from './plugins-proxy.controller'; import { PluginFrameController } from './plugin-frame.controller'; import { PluginsService } from './plugins.service'; import { PluginRuntimeService } from './plugin-runtime.service'; +import { PluginRegistryService } from './registry/registry.service'; /** * Plugin system (#plugins). M0 read side + M2 isolated runtime + M3 frontend: @@ -15,6 +16,6 @@ import { PluginRuntimeService } from './plugin-runtime.service'; */ @Module({ controllers: [PluginsController, PluginsFeedController, PluginsProxyController, PluginFrameController], - providers: [PluginsService, PluginRuntimeService], + providers: [PluginsService, PluginRuntimeService, PluginRegistryService], }) export class PluginsModule {} diff --git a/server/src/nest/plugins/registry/registry.service.ts b/server/src/nest/plugins/registry/registry.service.ts new file mode 100644 index 000000000..5f4a50ec6 --- /dev/null +++ b/server/src/nest/plugins/registry/registry.service.ts @@ -0,0 +1,148 @@ +import { Injectable } from '@nestjs/common'; +import fs from 'node:fs'; +import path from 'node:path'; +import { db } from '../../../db/database'; +import { pluginCodeDir, pluginsCodeRoot, pluginsDataRoot } from '../paths'; +import { safeDownload, sha256Matches } from '../install/safe-fetch'; +import { extractArchive } from '../install/safe-extract'; +import { scanForNativeBinaries } from '../install/native-scan'; +import { parseManifest } from '../install/manifest'; +import { discoverPlugins } from '../install/discovery'; + +/** + * TREK-side of the plugin registry (#plugins, M5). Fetches the single aggregated + * dist/index.json (never per-plugin GitHub API calls — the HACS rate-limit + * lesson), caches it briefly + soft-fails, and installs a pinned version through + * the M4 pipeline: SSRF-safe download -> sha256 verify -> zip/tar-slip-safe + * extract -> manifest re-validate -> native re-scan -> atomic move -> discover + * (registers INACTIVE). Nothing executes on install; activation is separate. + */ + +const REGISTRY_URL = + process.env.TREK_PLUGIN_REGISTRY_URL || + 'https://raw.githubusercontent.com/mauriceboe/TREK-Plugins/main/dist/index.json'; +const CACHE_TTL = 30 * 60 * 1000; + +interface RegistryVersion { + version: string; + gitTag: string; + commitSha: string; + downloadUrl: string; + sha256: string; + minTrekVersion: string; + maxTrekVersion?: string | null; + size?: number; + apiVersion?: number; + nativeModules?: boolean; +} +export interface RegistryEntry { + id: string; + name: string; + author: string; + description: string; + repo: string; + homepage?: string; + tags?: string[]; + type: string; + reviewedAt?: string | null; + versions: RegistryVersion[]; +} +interface Registry { + schemaVersion: number; + generatedAt?: string; + plugins: RegistryEntry[]; +} + +let _cache: { data: Registry; expiresAt: number } | null = null; + +/** Test hook: drop the in-memory registry cache. */ +export function __clearRegistryCacheForTests(): void { + _cache = null; +} + +export class RegistryError extends Error {} + +@Injectable() +export class PluginRegistryService { + /** Fetch the aggregated registry (cached, soft-fail, stale-serve). */ + async fetchRegistry(): Promise { + if (_cache && Date.now() < _cache.expiresAt) return _cache.data; + try { + const resp = await fetch(REGISTRY_URL, { headers: { 'User-Agent': 'TREK-Server' } }); + if (!resp.ok) throw new Error(`registry ${resp.status}`); + const data = (await resp.json()) as Registry; + if (!data || !Array.isArray(data.plugins)) throw new Error('malformed registry'); + _cache = { data, expiresAt: Date.now() + CACHE_TTL }; + return data; + } catch { + return _cache?.data ?? { schemaVersion: 1, plugins: [] }; + } + } + + /** The browse list the admin UI renders (metadata only, no code). */ + async browse(): Promise & { latest: string | null; minTrekVersion: string | null }>> { + const reg = await this.fetchRegistry(); + return reg.plugins.map((p) => { + const latest = p.versions[0] ?? null; + return { + id: p.id, name: p.name, author: p.author, description: p.description, repo: p.repo, + homepage: p.homepage, tags: p.tags, type: p.type, reviewedAt: p.reviewedAt ?? null, + latest: latest?.version ?? null, minTrekVersion: latest?.minTrekVersion ?? null, + }; + }); + } + + /** Install a pinned version from the registry. Returns the installed plugin id. */ + async install(id: string, version?: string): Promise<{ id: string; version: string }> { + const reg = await this.fetchRegistry(); + const entry = reg.plugins.find((p) => p.id === id); + if (!entry) throw new RegistryError(`plugin ${id} not in registry`); + const ver = version ? entry.versions.find((v) => v.version === version) : entry.versions[0]; + if (!ver) throw new RegistryError(`version ${version ?? 'latest'} not found for ${id}`); + + // 1. SSRF-safe download + 2. sha256 verify + const { bytes, sha256 } = await safeDownload(ver.downloadUrl, (ver.size ?? 50 * 1024 * 1024) + 4096); + if (!sha256Matches(sha256, ver.sha256)) throw new RegistryError('integrity check failed (sha256 mismatch)'); + + // 3. zip/tar-slip-safe extract to staging + const staging = path.join(pluginsDataRoot(), '.staging', `${id}-${ver.version}-${Date.now()}`); + try { + extractArchive(bytes, staging); + const pluginRoot = locateManifestDir(staging); + if (!pluginRoot) throw new RegistryError('archive contains no trek-plugin.json'); + + // 4. re-validate the bundled manifest + 5. native re-scan + const manifest = parseManifest(JSON.parse(fs.readFileSync(path.join(pluginRoot, 'trek-plugin.json'), 'utf8'))); + if (manifest.id !== id) throw new RegistryError(`manifest id "${manifest.id}" != "${id}"`); + if (scanForNativeBinaries(pluginRoot).length) throw new RegistryError('artifact contains native binaries'); + + // 6. atomic move into place + const dest = pluginCodeDir(id); + fs.mkdirSync(pluginsCodeRoot(), { recursive: true }); + fs.rmSync(dest, { recursive: true, force: true }); + fs.renameSync(pluginRoot, dest); + + // 7. register INACTIVE (record provenance) + discoverPlugins(db); + db.prepare('UPDATE plugins SET source_repo = ?, source_commit = ?, sha256 = ?, reviewed_at = ? WHERE id = ?').run( + entry.repo, ver.commitSha, ver.sha256, entry.reviewedAt ?? null, id, + ); + return { id, version: ver.version }; + } finally { + fs.rmSync(staging, { recursive: true, force: true }); + } + } +} + +/** The extracted plugin root: staging itself, or its single wrapper subdir (codeload archives wrap in {repo}-{sha}/). */ +function locateManifestDir(staging: string): string | null { + if (fs.existsSync(path.join(staging, 'trek-plugin.json'))) return staging; + const subs = fs.existsSync(staging) + ? fs.readdirSync(staging, { withFileTypes: true }).filter((d) => d.isDirectory()) + : []; + for (const s of subs) { + const p = path.join(staging, s.name); + if (fs.existsSync(path.join(p, 'trek-plugin.json'))) return p; + } + return null; +} diff --git a/server/tests/integration/plugins/registry.test.ts b/server/tests/integration/plugins/registry.test.ts new file mode 100644 index 000000000..57629315c --- /dev/null +++ b/server/tests/integration/plugins/registry.test.ts @@ -0,0 +1,170 @@ +/** + * TREK-side registry service (#plugins, M5): browse the aggregated registry and + * install a pinned version through the full verify -> extract -> validate -> + * move -> register pipeline (with the network download mocked). + */ +import { describe, it, expect, vi, 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 { createHash } from 'node:crypto'; + +const { safeDownload } = vi.hoisted(() => ({ safeDownload: vi.fn() })); +vi.mock('../../../src/nest/plugins/install/safe-fetch', async (orig) => ({ + ...(await orig>()), + safeDownload, +})); + +const { testDb } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const 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, + source_repo TEXT, source_commit TEXT, sha256 TEXT, reviewed_at 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);`); + return { testDb: db }; +}); +vi.mock('../../../src/db/database', () => ({ db: testDb, canAccessTrip: () => undefined })); + +import { PluginRegistryService, RegistryError, __clearRegistryCacheForTests } from '../../../src/nest/plugins/registry/registry.service'; + +// ── tiny tar.gz builder (wraps the plugin in a codeload-style top dir) ──────── +function tarHeader(name: string, size: number, typeflag = '0'): 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); h.write(typeflag, 156); 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 makeArtifact(manifest: object): Buffer { + const files = [ + { name: 'plug-abc/', type: '5' as const, data: '' }, + { name: 'plug-abc/trek-plugin.json', type: '0' as const, data: JSON.stringify(manifest) }, + { name: 'plug-abc/server/', type: '5' as const, data: '' }, + { name: 'plug-abc/server/index.js', type: '0' as const, data: 'module.exports={}' }, + ]; + const parts: Buffer[] = []; + for (const f of files) { + const body = Buffer.from(f.data); + parts.push(tarHeader(f.name, f.type === '5' ? 0 : body.length, f.type)); + if (f.type === '0') { 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)); +} + +const REGISTRY = { + schemaVersion: 1, + plugins: [ + { + id: 'flight-tracker', name: 'Flight', author: 'Acme', description: 'flights', repo: 'acme/trek-flight', + type: 'widget', reviewedAt: '2026-06-20', + versions: [{ version: '1.0.0', gitTag: 'v1.0.0', commitSha: 'a'.repeat(40), downloadUrl: 'https://codeload.github.com/acme/trek-flight/tar.gz/aaaa', sha256: '', minTrekVersion: '3.2.0' }], + }, + ], +}; + +let dataRoot: string; +let codeRoot: string; +let svc: PluginRegistryService; + +beforeEach(() => { + dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'reg-data-')); + codeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'reg-code-')); + process.env.TREK_PLUGINS_DATA_DIR = dataRoot; + process.env.TREK_PLUGINS_DIR = codeRoot; + testDb.exec('DELETE FROM plugins; DELETE FROM plugin_settings_fields; DELETE FROM plugin_error_log'); + __clearRegistryCacheForTests(); + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => REGISTRY }) as unknown as Response)); + svc = new PluginRegistryService(); +}); +afterEach(() => { + vi.unstubAllGlobals(); + safeDownload.mockReset(); + delete process.env.TREK_PLUGINS_DATA_DIR; + delete process.env.TREK_PLUGINS_DIR; + fs.rmSync(dataRoot, { recursive: true, force: true }); + fs.rmSync(codeRoot, { recursive: true, force: true }); +}); + +describe('PluginRegistryService', () => { + it('browse maps the aggregated registry to metadata', async () => { + const list = await svc.browse(); + expect(list).toEqual([ + expect.objectContaining({ id: 'flight-tracker', name: 'Flight', latest: '1.0.0', minTrekVersion: '3.2.0', reviewedAt: '2026-06-20' }), + ]); + }); + + it('fetchRegistry soft-fails to an empty registry on a cold cache', async () => { + __clearRegistryCacheForTests(); + vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); })); + expect((await new PluginRegistryService().fetchRegistry()).plugins).toEqual([]); + }); + + it('installs a pinned version end to end (verify -> extract -> register inactive)', async () => { + const artifact = makeArtifact({ id: 'flight-tracker', name: 'Flight', version: '1.0.0', type: 'widget', permissions: ['db:own'] }); + const sha = createHash('sha256').update(artifact).digest('hex'); + REGISTRY.plugins[0].versions[0].sha256 = sha; + safeDownload.mockResolvedValue({ bytes: artifact, sha256: sha }); + + const out = await svc.install('flight-tracker'); + expect(out).toEqual({ id: 'flight-tracker', version: '1.0.0' }); + + // moved into place + registered inactive with provenance + expect(fs.existsSync(path.join(codeRoot, 'flight-tracker', 'trek-plugin.json'))).toBe(true); + const row = testDb.prepare("SELECT status, source_repo, source_commit FROM plugins WHERE id='flight-tracker'").get() as { status: string; source_repo: string; source_commit: string }; + expect(row).toMatchObject({ status: 'inactive', source_repo: 'acme/trek-flight', source_commit: 'a'.repeat(40) }); + // no staging left behind + expect(fs.existsSync(path.join(dataRoot, '.staging'))).toBe(false || fs.readdirSync(path.join(dataRoot, '.staging')).length === 0); + }); + + it('rejects an sha256 mismatch', async () => { + const artifact = makeArtifact({ id: 'flight-tracker', name: 'Flight', version: '1.0.0', type: 'widget' }); + REGISTRY.plugins[0].versions[0].sha256 = 'b'.repeat(64); + safeDownload.mockResolvedValue({ bytes: artifact, sha256: 'c'.repeat(64) }); + await expect(svc.install('flight-tracker')).rejects.toThrow(/integrity/); + }); + + it('rejects an unknown plugin id', async () => { + await expect(svc.install('ghost')).rejects.toThrow(RegistryError); + }); + + it('caches the registry (one fetch across calls)', async () => { + const spy = vi.fn(async () => ({ ok: true, json: async () => REGISTRY }) as unknown as Response); + vi.stubGlobal('fetch', spy); + __clearRegistryCacheForTests(); + await svc.fetchRegistry(); + await svc.fetchRegistry(); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('soft-fails on a non-ok registry response', async () => { + __clearRegistryCacheForTests(); + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 500 }) as unknown as Response)); + expect((await svc.fetchRegistry()).plugins).toEqual([]); + }); + + it('rejects a version that is not listed', async () => { + await expect(svc.install('flight-tracker', '9.9.9')).rejects.toThrow(/not found/); + }); + + it('rejects an archive without a manifest', async () => { + const parts = [Buffer.alloc(1024, 0)]; // empty tar + const empty = zlib.gzipSync(Buffer.concat(parts)); + REGISTRY.plugins[0].versions[0].sha256 = createHash('sha256').update(empty).digest('hex'); + safeDownload.mockResolvedValue({ bytes: empty, sha256: REGISTRY.plugins[0].versions[0].sha256 }); + await expect(svc.install('flight-tracker')).rejects.toThrow(/no trek-plugin.json/); + }); + + it('rejects a manifest id that does not match the registry id', async () => { + const artifact = makeArtifact({ id: 'other-id', name: 'X', version: '1.0.0', type: 'widget' }); + REGISTRY.plugins[0].versions[0].sha256 = createHash('sha256').update(artifact).digest('hex'); + safeDownload.mockResolvedValue({ bytes: artifact, sha256: REGISTRY.plugins[0].versions[0].sha256 }); + await expect(svc.install('flight-tracker')).rejects.toThrow(/!=/); + }); +}); diff --git a/server/tests/unit/plugins/discovery.test.ts b/server/tests/unit/plugins/discovery.test.ts index 8a933b50b..2daac5d4f 100644 --- a/server/tests/unit/plugins/discovery.test.ts +++ b/server/tests/unit/plugins/discovery.test.ts @@ -44,7 +44,11 @@ describe('discoverPlugins', () => { name: 'Flight', type: 'widget', permissions: ['db:own'], - settings: [{ key: 'api_key', input_type: 'password', scope: 'instance', secret: true }], + settings: [ + { key: 'api_key', input_type: 'password', scope: 'instance', secret: true }, + { key: 'units', input_type: 'select', scope: 'user', options: [{ value: 'm', label: 'Metric' }] }, + { key: 'oauth', input_type: 'oauth', scope: 'user', oauth: { initPath: '/o/start', callbackPath: '/o/cb' } }, + ], }); const res = discoverPlugins(db); expect(res.discovered).toEqual(['flight-tracker']); diff --git a/server/tests/unit/plugins/plugins-service.test.ts b/server/tests/unit/plugins/plugins-service.test.ts index c0bacf54a..e8a3ef274 100644 --- a/server/tests/unit/plugins/plugins-service.test.ts +++ b/server/tests/unit/plugins/plugins-service.test.ts @@ -51,7 +51,7 @@ describe('PluginsService.list', () => { it('controller delegates to the service', () => { const svc = { list: vi.fn(() => ({ enabled: false, plugins: [] })) } as unknown as PluginsService; const runtime = {} as unknown as import('../../../src/nest/plugins/plugin-runtime.service').PluginRuntimeService; - const res = new PluginsController(svc, runtime).list(); + const res = new PluginsController(svc, runtime, {} as never).list(); expect(svc.list).toHaveBeenCalled(); expect(res).toEqual({ enabled: false, plugins: [] }); }); @@ -86,31 +86,31 @@ describe('PluginsController M2 endpoints', () => { it('get/update config delegate to the service', () => { const rt = { activate: vi.fn(), deactivate: vi.fn(), isActive: vi.fn() } as never; - const c = new PluginsController(svc, rt); + const c = new PluginsController(svc, rt, {} as never); expect(c.getConfig('x')).toEqual({ config: { a: 1 } }); expect(c.updateConfig('x', { a: 2 })).toEqual({ config: { a: 2 } }); }); it('activate spawns via the runtime when enabled', async () => { const rt = { activate: vi.fn(async () => {}), isActive: vi.fn(() => true) } as never; - const out = await new PluginsController(svc, rt).activate('x'); + const out = await new PluginsController(svc, rt, {} as never).activate('x'); expect(out).toEqual({ status: 'active' }); }); it('activate is 503 when the runtime is disabled', async () => { process.env.TREK_PLUGINS_ENABLED = 'false'; const rt = { activate: vi.fn(), isActive: vi.fn() } as never; - await expect(new PluginsController(svc, rt).activate('x')).rejects.toMatchObject({ status: 503 }); + await expect(new PluginsController(svc, rt, {} as never).activate('x')).rejects.toMatchObject({ status: 503 }); }); it('activate surfaces an activation error as 400', async () => { const rt = { activate: vi.fn(async () => { throw new Error('bad code'); }), isActive: vi.fn(() => false) } as never; - await expect(new PluginsController(svc, rt).activate('x')).rejects.toMatchObject({ status: 400 }); + await expect(new PluginsController(svc, rt, {} as never).activate('x')).rejects.toMatchObject({ status: 400 }); }); it('deactivate stops the plugin', async () => { const rt = { deactivate: vi.fn(async () => {}) } as never; - expect(await new PluginsController(svc, rt).deactivate('x')).toEqual({ status: 'inactive' }); + expect(await new PluginsController(svc, rt, {} as never).deactivate('x')).toEqual({ status: 'inactive' }); }); }); diff --git a/server/tests/unit/plugins/safe-extract.test.ts b/server/tests/unit/plugins/safe-extract.test.ts index 02b355a48..723d305f8 100644 --- a/server/tests/unit/plugins/safe-extract.test.ts +++ b/server/tests/unit/plugins/safe-extract.test.ts @@ -43,22 +43,23 @@ function makeTarGz(entries: Array<{ name: string; data?: string; type?: string; parts.push(Buffer.alloc(1024, 0)); return zlib.gzipSync(Buffer.concat(parts)); } -function makeStoredZip(name: string, data: string): Buffer { +function makeZip(name: string, data: string, method = 0): Buffer { const nameB = Buffer.from(name); - const dataB = Buffer.from(data); + const raw = Buffer.from(data); + const comp = method === 8 ? zlib.deflateRawSync(raw) : raw; 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(method, 8); + local.writeUInt32LE(comp.length, 18); + local.writeUInt32LE(raw.length, 22); local.writeUInt16LE(nameB.length, 26); - const localRec = Buffer.concat([local, nameB, dataB]); + const localRec = Buffer.concat([local, nameB, comp]); 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(method, 10); + central.writeUInt32LE(comp.length, 20); + central.writeUInt32LE(raw.length, 24); central.writeUInt16LE(nameB.length, 28); central.writeUInt32LE(0, 42); // local header offset const centralRec = Buffer.concat([central, nameB]); @@ -101,10 +102,16 @@ describe('extractArchive', () => { }); it('extracts a stored zip', () => { - extractArchive(makeStoredZip('a.txt', 'hello'), dest); + extractArchive(makeZip('a.txt', 'hello'), dest); expect(fs.readFileSync(path.join(dest, 'a.txt'), 'utf8')).toBe('hello'); }); + it('extracts a deflated zip and rejects an unsupported compression method', () => { + extractArchive(makeZip('b.txt', 'world', 8), dest); + expect(fs.readFileSync(path.join(dest, 'b.txt'), 'utf8')).toBe('world'); + expect(() => extractArchive(makeZip('c.txt', 'x', 99), dest)).toThrow(/unsupported zip compression/); + }); + it('refuses a symlink entry', () => { const gz = makeTarGz([{ name: 'link', type: '2', link: '/etc/passwd' }]); expect(() => extractArchive(gz, dest)).toThrow(/non-regular/); diff --git a/server/tests/unit/plugins/safe-fetch.test.ts b/server/tests/unit/plugins/safe-fetch.test.ts index 4c958d3b2..7293d802e 100644 --- a/server/tests/unit/plugins/safe-fetch.test.ts +++ b/server/tests/unit/plugins/safe-fetch.test.ts @@ -58,6 +58,15 @@ describe('safeDownload', () => { expect(fetchMock).toHaveBeenCalledTimes(2); }); + it('rejects an invalid url', async () => { + await expect(safeDownload('not-a-url')).rejects.toThrow(/invalid url/); + }); + + it('rejects a redirect without a location header', async () => { + vi.stubGlobal('fetch', vi.fn(async () => ({ status: 302, headers: new Headers() }) as unknown as Response)); + await expect(safeDownload('https://github.com/x')).rejects.toThrow(/redirect without/); + }); + 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);