mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
f61e144e11
Connects TREK to the static GitHub registry (mauriceboe/TREK-Plugins). The
registry repo + CI gates were already live; this is the server side.
- registry.service: fetches the single aggregated dist/index.json (never
per-plugin GitHub API calls — the HACS rate-limit lesson), caches it 30 min,
soft-fails to a stale/empty registry, and installs a pinned version through
the M4 pipeline: safe download -> sha256 verify -> slip-safe extract ->
manifest re-validate -> native re-scan -> atomic move -> discover (inactive),
recording repo/commit/sha provenance. Handles the codeload {repo}-{sha}/
wrapper directory.
- Admin endpoints: GET /api/admin/plugins/registry (browse metadata) and
POST /api/admin/plugins/install { id, version }. Install never executes code;
activation stays a separate, deliberate step.
76 lines
3.1 KiB
TypeScript
76 lines
3.1 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('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);
|
|
await expect(safeDownload('https://github.com/x', 1000)).rejects.toThrow(DownloadError);
|
|
});
|
|
});
|