// 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', ]); // JetBrains Marketplace — explicit 3-host allowlist per research brief §3. // Do NOT use a wildcard suffix match on the jetbrains.com apex — any subdomain // (evil.jetbrains.com via subdomain takeover) would pass and constitute a // security regression. Use exact-match Set membership only. const JETBRAINS_ALLOWED_HOSTS = new Set([ 'plugins.jetbrains.com', 'downloads.marketplace.jetbrains.com', 'cache-redirector.jetbrains.com', ]); const MAX_JETBRAINS_META_BYTES = 8 * 1024; // 8KB cap for JSON metadata responses /** * 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' }; } // JetBrains Marketplace: /plugin/7973-intellivue (numeric ID + optional slug) // or /plugin/download?pluginId=xmlId (direct download by xmlId). if (u.hostname === 'plugins.jetbrains.com') { // Download-by-xmlId path: /plugin/download?pluginId= if (u.pathname === '/plugin/download') { const xmlId = u.searchParams.get('pluginId'); if (xmlId && /^[A-Za-z0-9._-]+$/.test(xmlId)) { const version = u.searchParams.get('version'); return { type: 'jetbrains', numericId: null, xmlId, version: version || null }; } return { type: 'unknown' }; } // Plugin page: /plugin/[-] const m = u.pathname.match(/^\/plugin\/(\d+)(?:-[^/]+)?\/?$/); if (m) { return { type: 'jetbrains', numericId: m[1], xmlId: null, version: null }; } return { type: 'unknown' }; } // 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'); } if (originalType === 'jetbrains') { // Strict 3-host Set — exact match only; no wildcard suffix check. return JETBRAINS_ALLOWED_HOSTS.has(hostname); } // 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); } } /** * Fetch a JetBrains plugin from plugins.jetbrains.com. * * Two paths: * 1. `xmlId` present → direct download: * GET https://plugins.jetbrains.com/plugin/download?pluginId=[&version=] * Redirects through cache-redirector.jetbrains.com / downloads.marketplace.jetbrains.com. * 2. Only `numericId` present → first resolve to xmlId via the public API: * GET https://plugins.jetbrains.com/api/plugins/ (JSON, capped at 8KB) * Then follow path 1. * * All requests go through `httpsFetch(url, init, 'jetbrains')` which validates * every redirect hop against `JETBRAINS_ALLOWED_HOSTS`. * * @param {{numericId?: string|null, xmlId?: string|null, version?: string|null}} params * @returns {Promise<{buffer:Buffer, sha256:string, size:number, finalUrl:string, source:object}>} */ export async function fetchJetBrainsPlugin(params) { const { numericId = null, xmlId: initialXmlId = null, version = null } = params || {}; let xmlId = initialXmlId; // Resolve numericId → xmlId if needed. if (!xmlId) { if (!numericId) throw new Error('fetchJetBrainsPlugin: need xmlId or numericId'); if (!/^\d+$/.test(String(numericId))) { throw new Error(`fetchJetBrainsPlugin: invalid numericId: ${numericId}`); } const metaUrl = `https://plugins.jetbrains.com/api/plugins/${encodeURIComponent(numericId)}`; const meta = await httpsFetch( metaUrl, { method: 'GET', headers: { Accept: 'application/json' } }, 'jetbrains', ); if (meta.size > MAX_JETBRAINS_META_BYTES) { throw new Error(`JetBrains metadata exceeds ${MAX_JETBRAINS_META_BYTES} bytes`); } let info; try { info = JSON.parse(meta.buffer.toString('utf8')); } catch { throw new Error('JetBrains API returned non-JSON metadata'); } if (!info || typeof info.xmlId !== 'string' || !info.xmlId) { throw new Error('JetBrains API metadata missing xmlId'); } xmlId = info.xmlId; } // Validate xmlId shape before putting it in a URL. if (!/^[A-Za-z0-9._-]+$/.test(xmlId)) { throw new Error(`fetchJetBrainsPlugin: suspicious xmlId: ${xmlId}`); } let downloadUrl = `https://plugins.jetbrains.com/plugin/download?pluginId=${encodeURIComponent(xmlId)}`; if (version) { if (!/^[A-Za-z0-9._+-]+$/.test(version)) { throw new Error(`fetchJetBrainsPlugin: suspicious version: ${version}`); } downloadUrl += `&version=${encodeURIComponent(version)}`; } const out = await httpsFetch(downloadUrl, { method: 'GET' }, 'jetbrains'); return { ...out, source: { type: 'jetbrains', numericId, xmlId, version, requestedUrl: downloadUrl, }, }; } /** * High-level dispatch that covers both VS Code family (VSIX) and JetBrains. * New callers should prefer this over `fetchVsixFromUrl` — it delegates based * on detected URL type. Existing `fetchVsixFromUrl` callers are preserved. * @param {string} url */ export async function fetchPluginFromUrl(url) { const detected = detectUrlType(url); if (detected.type === 'jetbrains') { return fetchJetBrainsPlugin({ numericId: detected.numericId, xmlId: detected.xmlId, version: detected.version, }); } return fetchVsixFromUrl(url); } /** * 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 'jetbrains': // Route via the JetBrains-specific fetcher so callers that accidentally // hit the VSIX dispatch with a JB URL still work. Real orchestration // should use `fetchPluginFromUrl` or check `detectUrlType` first. return fetchJetBrainsPlugin({ numericId: detected.numericId, xmlId: detected.xmlId, version: detected.version, }); 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, MAX_JETBRAINS_META_BYTES, FETCH_TIMEOUT_MS, JETBRAINS_ALLOWED_HOSTS, isAllowedHost, readBodyCapped, };