From a7dc4fbfe77fc15c8036768f0a6385b8d5534df1 Mon Sep 17 00:00:00 2001 From: mauriceboe Date: Fri, 3 Jul 2026 18:28:16 +0200 Subject: [PATCH] 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. --- server/src/nest/plugins/install/safe-fetch.ts | 14 ++++++++++++-- server/tests/unit/plugins/safe-fetch.test.ts | 5 +++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/server/src/nest/plugins/install/safe-fetch.ts b/server/src/nest/plugins/install/safe-fetch.ts index 374358eaa..94cd76f51 100644 --- a/server/src/nest/plugins/install/safe-fetch.ts +++ b/server/src/nest/plugins/install/safe-fetch.ts @@ -10,12 +10,22 @@ import dns from 'node:dns/promises'; * 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 {} +/** + * GitHub delivery hosts only. Release-asset downloads 302 through a rotating + * `*.githubusercontent.com` host (objects / github-releases / release-assets …), + * so we allow that whole suffix plus github.com and codeload. The private-IP + * check below is the SSRF backstop regardless of host. + */ +function isAllowedHost(hostname: string): boolean { + const h = hostname.toLowerCase(); + return h === 'github.com' || h === 'codeload.github.com' || h.endsWith('.githubusercontent.com'); +} + export function isPrivateIp(ip: string): boolean { const v4 = ip.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/); if (v4) { @@ -41,7 +51,7 @@ async function assertSafeHost(urlStr: string): Promise { 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}`); + if (!isAllowedHost(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) { diff --git a/server/tests/unit/plugins/safe-fetch.test.ts b/server/tests/unit/plugins/safe-fetch.test.ts index 7293d802e..8befdd916 100644 --- a/server/tests/unit/plugins/safe-fetch.test.ts +++ b/server/tests/unit/plugins/safe-fetch.test.ts @@ -44,11 +44,12 @@ describe('safeDownload', () => { await expect(safeDownload('https://github.com/x.zip')).rejects.toThrow(/private address/); }); - it('downloads and hashes, following an allowlisted redirect', async () => { + it('downloads and hashes, following a GitHub release-asset 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' }) }) + // 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);