Files
TREK/server/tests/unit/plugins/safe-fetch.test.ts
T
mauriceboe be07f29acd 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.
2026-07-03 17:26:46 +02:00

67 lines
2.7 KiB
TypeScript

/**
* 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);
});
});