Pre-installation verification of VS Code extensions via URL — fetch a remote VSIX, extract it in a hardened sandbox, and run the existing IDE scanner pipeline against it. No npm dependencies. Sources: - VS Code Marketplace (publisher.gallery.vsassets.io direct download) - OpenVSX (open-vsx.org official API) - Direct .vsix HTTPS URLs Defenses: - HTTPS-only, TLS verified, manual redirect with per-source host whitelist - 30s total timeout via AbortController - 50MB compressed cap, 500MB uncompressed, 100x expansion ratio - Zero-dep ZIP extractor: zip-slip, absolute paths, drive letters, NUL bytes, symlinks (Unix mode 0xA000), depth limits, ZIP64 rejected, encrypted rejected - SHA-256 streamed during fetch, surfaced in meta.source - Temp dir cleanup in all paths (try/finally) Files: - scanners/lib/vsix-fetch.mjs (HTTPS fetcher, host whitelist, streaming SHA-256) - scanners/lib/zip-extract.mjs (zero-dep parser with hardening caps) - knowledge/marketplace-api-notes.md (endpoint reference) - 3 test files (48 tests added: vsix-fetch, zip-extract, ide-extension-url) Tests: 1296 → 1344 (all green). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
285 lines
10 KiB
JavaScript
285 lines
10 KiB
JavaScript
// vsix-fetch.mjs — Fetch VSIX packages from VS Code Marketplace, OpenVSX, or direct URL.
|
|
// Zero dependencies. Streams to memory with strict size cap, computes SHA-256 on the fly.
|
|
//
|
|
// Defenses:
|
|
// - HTTPS only (no plain HTTP, no protocol downgrade on redirects)
|
|
// - 30s total timeout (network + body)
|
|
// - 50MB max compressed size (abort streaming when exceeded)
|
|
// - TLS verification always enabled
|
|
// - No follow on cross-origin redirects (same registered host only)
|
|
// - Marketplace endpoint is undocumented but stable; documented in
|
|
// knowledge/marketplace-api-notes.md.
|
|
|
|
import { createHash } from 'node:crypto';
|
|
|
|
const MAX_VSIX_BYTES = 50 * 1024 * 1024; // 50MB
|
|
const FETCH_TIMEOUT_MS = 30_000;
|
|
|
|
const MARKETPLACE_HOSTS = new Set([
|
|
'marketplace.visualstudio.com',
|
|
]);
|
|
const OPENVSX_HOSTS = new Set([
|
|
'open-vsx.org',
|
|
]);
|
|
|
|
/**
|
|
* Detect what kind of URL this is.
|
|
* @param {string} url
|
|
* @returns {{ type: 'marketplace'|'openvsx'|'vsix'|'github'|'unknown', publisher?: string, name?: string, version?: string }}
|
|
*/
|
|
export function detectUrlType(url) {
|
|
let u;
|
|
try { u = new URL(url); } catch { return { type: 'unknown' }; }
|
|
if (u.protocol !== 'https:') return { type: 'unknown' };
|
|
|
|
// VS Code Marketplace: items?itemName=publisher.name
|
|
if (MARKETPLACE_HOSTS.has(u.hostname)) {
|
|
const itemName = u.searchParams.get('itemName');
|
|
if (!itemName || !itemName.includes('.')) return { type: 'unknown' };
|
|
const dot = itemName.indexOf('.');
|
|
const publisher = itemName.slice(0, dot);
|
|
const name = itemName.slice(dot + 1);
|
|
if (!publisher || !name) return { type: 'unknown' };
|
|
return { type: 'marketplace', publisher, name };
|
|
}
|
|
|
|
// OpenVSX: /extension/{publisher}/{name}[/{version}]
|
|
if (OPENVSX_HOSTS.has(u.hostname)) {
|
|
const parts = u.pathname.split('/').filter(Boolean);
|
|
if (parts[0] !== 'extension' || parts.length < 3) return { type: 'unknown' };
|
|
const [, publisher, name, version] = parts;
|
|
return { type: 'openvsx', publisher, name, version: version || null };
|
|
}
|
|
|
|
// GitHub repo (not supported in v6.4.0)
|
|
if (u.hostname === 'github.com') {
|
|
return { type: 'github' };
|
|
}
|
|
|
|
// Direct .vsix link
|
|
if (u.pathname.toLowerCase().endsWith('.vsix')) {
|
|
return { type: 'vsix' };
|
|
}
|
|
|
|
return { type: 'unknown' };
|
|
}
|
|
|
|
function isAllowedHost(hostname, originalType) {
|
|
if (originalType === 'marketplace') {
|
|
// Marketplace API redirects to vsassets cdn (vstmrblob).
|
|
return MARKETPLACE_HOSTS.has(hostname)
|
|
|| hostname.endsWith('.gallerycdn.vsassets.io')
|
|
|| hostname.endsWith('.vsassets.io');
|
|
}
|
|
if (originalType === 'openvsx') {
|
|
return OPENVSX_HOSTS.has(hostname)
|
|
|| hostname === 'openvsxorg.blob.core.windows.net'
|
|
|| hostname.endsWith('.openvsx.org');
|
|
}
|
|
// Direct vsix: only same host as the original URL (caller enforces).
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Stream the body of a Response into a Buffer with size cap and SHA-256.
|
|
* Aborts via the AbortController if cap is exceeded.
|
|
* @param {Response} res
|
|
* @param {AbortController} controller
|
|
* @returns {Promise<{ buffer: Buffer, sha256: string, size: number }>}
|
|
*/
|
|
async function readBodyCapped(res, controller) {
|
|
if (!res.body) throw new Error('response has no body');
|
|
const hash = createHash('sha256');
|
|
const chunks = [];
|
|
let size = 0;
|
|
const reader = res.body.getReader();
|
|
// eslint-disable-next-line no-constant-condition
|
|
while (true) {
|
|
const { value, done } = await reader.read();
|
|
if (done) break;
|
|
if (!value) continue;
|
|
size += value.byteLength;
|
|
if (size > MAX_VSIX_BYTES) {
|
|
try { controller.abort(); } catch {}
|
|
throw new Error(`VSIX exceeds maximum size (${MAX_VSIX_BYTES} bytes)`);
|
|
}
|
|
hash.update(value);
|
|
chunks.push(Buffer.from(value));
|
|
}
|
|
return { buffer: Buffer.concat(chunks), sha256: hash.digest('hex'), size };
|
|
}
|
|
|
|
async function httpsFetch(url, init, originalType) {
|
|
const u = new URL(url);
|
|
if (u.protocol !== 'https:') {
|
|
throw new Error(`refusing non-HTTPS URL: ${url}`);
|
|
}
|
|
if (!isAllowedHost(u.hostname, originalType)) {
|
|
throw new Error(`refusing redirect to disallowed host: ${u.hostname}`);
|
|
}
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
try {
|
|
const res = await fetch(url, {
|
|
...init,
|
|
signal: controller.signal,
|
|
// Manual redirect handling so we can validate every hop.
|
|
redirect: 'manual',
|
|
});
|
|
if (res.status >= 300 && res.status < 400) {
|
|
const loc = res.headers.get('location');
|
|
if (!loc) throw new Error(`HTTP ${res.status} without Location header`);
|
|
const next = new URL(loc, url).toString();
|
|
// Cap redirect depth via init counter.
|
|
const depth = (init && init.__depth) || 0;
|
|
if (depth >= 5) throw new Error('too many redirects');
|
|
return httpsFetch(next, { ...init, __depth: depth + 1, method: 'GET', body: undefined }, originalType);
|
|
}
|
|
if (!res.ok) {
|
|
throw new Error(`HTTP ${res.status} ${res.statusText} for ${url}`);
|
|
}
|
|
const out = await readBodyCapped(res, controller);
|
|
return { ...out, finalUrl: url };
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch a VSIX from the VS Code Marketplace by publisher.name.
|
|
* Uses the undocumented but stable gallery API:
|
|
* POST https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery
|
|
* The response includes a download URL; we then GET that.
|
|
* Falls back to the well-known direct URL pattern if extensionquery is not usable.
|
|
*
|
|
* @param {string} publisher
|
|
* @param {string} name
|
|
* @returns {Promise<{ buffer: Buffer, sha256: string, size: number, finalUrl: string, source: object }>}
|
|
*/
|
|
export async function fetchMarketplaceVsix(publisher, name) {
|
|
// Direct download URL pattern (well-known, used by `vsce` and `code` itself):
|
|
// https://{publisher}.gallery.vsassets.io/_apis/public/gallery/publisher/{publisher}/extension/{name}/latest/assetbyname/Microsoft.VisualStudio.Services.VSIXPackage
|
|
const directUrl =
|
|
`https://${encodeURIComponent(publisher)}.gallery.vsassets.io` +
|
|
`/_apis/public/gallery/publisher/${encodeURIComponent(publisher)}` +
|
|
`/extension/${encodeURIComponent(name)}/latest/assetbyname/Microsoft.VisualStudio.Services.VSIXPackage`;
|
|
|
|
const out = await httpsFetch(directUrl, { method: 'GET' }, 'marketplace');
|
|
return {
|
|
...out,
|
|
source: { type: 'marketplace', publisher, name, requestedUrl: directUrl },
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Fetch a VSIX from OpenVSX. If version is omitted, hits the "latest" endpoint to resolve.
|
|
* Direct file pattern:
|
|
* https://open-vsx.org/api/{pub}/{name}/{version}/file/{pub}.{name}-{version}.vsix
|
|
* Without version we hit:
|
|
* https://open-vsx.org/api/{pub}/{name}/latest
|
|
* to resolve, then download.
|
|
*
|
|
* @param {string} publisher
|
|
* @param {string} name
|
|
* @param {string|null} version
|
|
*/
|
|
export async function fetchOpenVsxVsix(publisher, name, version) {
|
|
let resolvedVersion = version;
|
|
if (!resolvedVersion) {
|
|
const meta = await httpsFetch(
|
|
`https://open-vsx.org/api/${encodeURIComponent(publisher)}/${encodeURIComponent(name)}/latest`,
|
|
{ method: 'GET', headers: { Accept: 'application/json' } },
|
|
'openvsx',
|
|
);
|
|
let info;
|
|
try { info = JSON.parse(meta.buffer.toString('utf8')); }
|
|
catch { throw new Error('OpenVSX returned non-JSON metadata'); }
|
|
if (!info || typeof info.version !== 'string') {
|
|
throw new Error('OpenVSX metadata missing version');
|
|
}
|
|
resolvedVersion = info.version;
|
|
}
|
|
|
|
const url =
|
|
`https://open-vsx.org/api/${encodeURIComponent(publisher)}/${encodeURIComponent(name)}` +
|
|
`/${encodeURIComponent(resolvedVersion)}/file/` +
|
|
`${encodeURIComponent(publisher)}.${encodeURIComponent(name)}-${encodeURIComponent(resolvedVersion)}.vsix`;
|
|
|
|
const out = await httpsFetch(url, { method: 'GET' }, 'openvsx');
|
|
return {
|
|
...out,
|
|
source: { type: 'openvsx', publisher, name, version: resolvedVersion, requestedUrl: url },
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Fetch a VSIX from a direct URL.
|
|
* @param {string} url
|
|
*/
|
|
export async function fetchDirectVsix(url) {
|
|
const u = new URL(url);
|
|
if (u.protocol !== 'https:') {
|
|
throw new Error('direct VSIX URL must be HTTPS');
|
|
}
|
|
// Track host so redirects must stay on the same registered host.
|
|
const sourceHost = u.hostname;
|
|
const out = await httpsFetchSameHost(url, sourceHost);
|
|
return {
|
|
...out,
|
|
source: { type: 'vsix', requestedUrl: url },
|
|
};
|
|
}
|
|
|
|
async function httpsFetchSameHost(url, sourceHost) {
|
|
const u = new URL(url);
|
|
if (u.protocol !== 'https:') {
|
|
throw new Error(`refusing non-HTTPS URL: ${url}`);
|
|
}
|
|
if (u.hostname !== sourceHost) {
|
|
throw new Error(`refusing cross-host redirect: ${u.hostname} != ${sourceHost}`);
|
|
}
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
try {
|
|
const res = await fetch(url, { signal: controller.signal, redirect: 'manual' });
|
|
if (res.status >= 300 && res.status < 400) {
|
|
const loc = res.headers.get('location');
|
|
if (!loc) throw new Error(`HTTP ${res.status} without Location header`);
|
|
const next = new URL(loc, url).toString();
|
|
return httpsFetchSameHost(next, sourceHost);
|
|
}
|
|
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText} for ${url}`);
|
|
const out = await readBodyCapped(res, controller);
|
|
return { ...out, finalUrl: url };
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* High-level dispatch. Detects URL type and returns a fetched VSIX.
|
|
* @param {string} url
|
|
* @returns {Promise<{ buffer: Buffer, sha256: string, size: number, finalUrl: string, source: object }>}
|
|
*/
|
|
export async function fetchVsixFromUrl(url) {
|
|
const detected = detectUrlType(url);
|
|
switch (detected.type) {
|
|
case 'marketplace':
|
|
return fetchMarketplaceVsix(detected.publisher, detected.name);
|
|
case 'openvsx':
|
|
return fetchOpenVsxVsix(detected.publisher, detected.name, detected.version);
|
|
case 'vsix':
|
|
return fetchDirectVsix(url);
|
|
case 'github':
|
|
throw new Error('GitHub repo URLs are not supported in v6.4.0 (would require build step). Use Marketplace, OpenVSX, or a direct .vsix URL.');
|
|
default:
|
|
throw new Error(`unsupported URL: ${url}`);
|
|
}
|
|
}
|
|
|
|
export const __testing = {
|
|
MAX_VSIX_BYTES,
|
|
FETCH_TIMEOUT_MS,
|
|
isAllowedHost,
|
|
readBodyCapped,
|
|
};
|