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.
This commit is contained in:
mauriceboe
2026-07-03 18:28:16 +02:00
committed by Maurice
parent a920c6f72a
commit a7dc4fbfe7
2 changed files with 15 additions and 4 deletions
+12 -2
View File
@@ -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<void> {
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) {
+3 -2
View File
@@ -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);