mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user