feat(llm-security): add fetchJetBrainsPlugin + URL detection for plugins.jetbrains.com

This commit is contained in:
Kjell Tore Guttormsen 2026-04-18 10:39:54 +02:00
commit 23455e5a66
2 changed files with 315 additions and 2 deletions

View file

@ -22,6 +22,18 @@ 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
@ -56,6 +68,26 @@ export function detectUrlType(url) {
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=<xmlId>
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/<numericId>[-<slug>]
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' };
@ -76,6 +108,10 @@ function isAllowedHost(hostname, originalType) {
|| 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;
}
@ -256,6 +292,95 @@ async function httpsFetchSameHost(url, sourceHost) {
}
}
/**
* Fetch a JetBrains plugin from plugins.jetbrains.com.
*
* Two paths:
* 1. `xmlId` present direct download:
* GET https://plugins.jetbrains.com/plugin/download?pluginId=<xmlId>[&version=<v>]
* 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/<numericId> (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
@ -270,6 +395,15 @@ export async function fetchVsixFromUrl(url) {
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:
@ -279,7 +413,9 @@ export async function fetchVsixFromUrl(url) {
export const __testing = {
MAX_VSIX_BYTES,
MAX_JETBRAINS_META_BYTES,
FETCH_TIMEOUT_MS,
JETBRAINS_ALLOWED_HOSTS,
isAllowedHost,
readBodyCapped,
};