Files
TREK/server/tests/unit/plugins/safe-fetch.test.ts
T
mauriceboe a7dc4fbfe7 fix(plugins): allow GitHub's rotating release-asset host in the installer
GitHub 302-redirects release-asset downloads to a rotating *.githubusercontent.com
host (objects / github-releases / release-assets). The SSRF allowlist only had
objects.githubusercontent.com, so installs failed with 'host not allowlisted'.
Allow the whole *.githubusercontent.com suffix (plus github.com/codeload); the
private-IP check remains the SSRF backstop.
2026-07-04 19:11:23 +02:00

77 lines
3.2 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 a GitHub release-asset redirect', async () => {
const bytes = Buffer.from('plugin-bytes');
const fetchMock = vi
.fn()
// GitHub 302s release assets to a rotating *.githubusercontent.com host
.mockResolvedValueOnce({ status: 302, headers: new Headers({ location: 'https://release-assets.githubusercontent.com/x/plugin.zip' }) })
.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);
});
});