diff --git a/plugins/llm-security/scanners/lib/vsix-fetch.mjs b/plugins/llm-security/scanners/lib/vsix-fetch.mjs index 8342612..3f91921 100644 --- a/plugins/llm-security/scanners/lib/vsix-fetch.mjs +++ b/plugins/llm-security/scanners/lib/vsix-fetch.mjs @@ -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= + 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' }; @@ -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=[&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 @@ -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, }; diff --git a/plugins/llm-security/tests/scanners/vsix-fetch.test.mjs b/plugins/llm-security/tests/scanners/vsix-fetch.test.mjs index 72b05ee..9f7ff8d 100644 --- a/plugins/llm-security/tests/scanners/vsix-fetch.test.mjs +++ b/plugins/llm-security/tests/scanners/vsix-fetch.test.mjs @@ -2,9 +2,14 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { detectUrlType, __testing } from '../../scanners/lib/vsix-fetch.mjs'; +import { + detectUrlType, + fetchJetBrainsPlugin, + fetchPluginFromUrl, + __testing, +} from '../../scanners/lib/vsix-fetch.mjs'; -const { isAllowedHost, readBodyCapped, MAX_VSIX_BYTES } = __testing; +const { isAllowedHost, readBodyCapped, MAX_VSIX_BYTES, JETBRAINS_ALLOWED_HOSTS } = __testing; describe('detectUrlType', () => { it('detects VS Code Marketplace URL', () => { @@ -124,3 +129,175 @@ describe('readBodyCapped', () => { await assert.rejects(() => readBodyCapped(res, ctrl), /exceeds maximum size/); }); }); + +// --------------------------------------------------------------------------- +// JetBrains Marketplace — URL detection + host whitelist + fetch happy path +// --------------------------------------------------------------------------- + +describe('detectUrlType — JetBrains Marketplace', () => { + it('detects /plugin/-', () => { + const out = detectUrlType('https://plugins.jetbrains.com/plugin/7973-intellivue'); + assert.equal(out.type, 'jetbrains'); + assert.equal(out.numericId, '7973'); + assert.equal(out.xmlId, null); + }); + + it('detects /plugin/ without slug', () => { + const out = detectUrlType('https://plugins.jetbrains.com/plugin/7973'); + assert.equal(out.type, 'jetbrains'); + assert.equal(out.numericId, '7973'); + }); + + it('detects /plugin/download?pluginId=', () => { + const out = detectUrlType('https://plugins.jetbrains.com/plugin/download?pluginId=com.example.plugin'); + assert.equal(out.type, 'jetbrains'); + assert.equal(out.xmlId, 'com.example.plugin'); + assert.equal(out.numericId, null); + }); + + it('detects /plugin/download?pluginId=&version=', () => { + const out = detectUrlType('https://plugins.jetbrains.com/plugin/download?pluginId=com.example&version=1.2.3'); + assert.equal(out.type, 'jetbrains'); + assert.equal(out.xmlId, 'com.example'); + assert.equal(out.version, '1.2.3'); + }); + + it('rejects /plugin/download without pluginId', () => { + const out = detectUrlType('https://plugins.jetbrains.com/plugin/download'); + assert.equal(out.type, 'unknown'); + }); + + it('rejects malformed xmlId (special characters)', () => { + const out = detectUrlType('https://plugins.jetbrains.com/plugin/download?pluginId=evil%3B%20ls'); + assert.equal(out.type, 'unknown'); + }); + + it('rejects non-numeric numericId', () => { + const out = detectUrlType('https://plugins.jetbrains.com/plugin/abc-intellivue'); + assert.equal(out.type, 'unknown'); + }); +}); + +describe('isAllowedHost — JetBrains host whitelist', () => { + it('accepts plugins.jetbrains.com', () => { + assert.equal(isAllowedHost('plugins.jetbrains.com', 'jetbrains'), true); + }); + + it('accepts downloads.marketplace.jetbrains.com', () => { + assert.equal(isAllowedHost('downloads.marketplace.jetbrains.com', 'jetbrains'), true); + }); + + it('accepts cache-redirector.jetbrains.com', () => { + assert.equal(isAllowedHost('cache-redirector.jetbrains.com', 'jetbrains'), true); + }); + + it('rejects evil.jetbrains.com (subdomain takeover defense)', () => { + assert.equal(isAllowedHost('evil.jetbrains.com', 'jetbrains'), false); + }); + + it('rejects unrelated host attacker.example.com', () => { + assert.equal(isAllowedHost('attacker.example.com', 'jetbrains'), false); + }); + + it('rejects typosquat jetbrains.com.evil.com', () => { + assert.equal(isAllowedHost('jetbrains.com.evil.com', 'jetbrains'), false); + }); + + it('JETBRAINS_ALLOWED_HOSTS has exactly 3 entries', () => { + assert.equal(JETBRAINS_ALLOWED_HOSTS.size, 3); + }); +}); + +describe('fetchJetBrainsPlugin — happy path with mocked fetch', () => { + it('fetches by xmlId directly (no metadata lookup needed)', async () => { + const origFetch = globalThis.fetch; + const fakeVsix = Buffer.from('PK\x03\x04fake-jar-bytes-for-test'); + try { + globalThis.fetch = async (url) => { + assert.match(String(url), /plugin\/download\?pluginId=com\.example/); + return new Response(fakeVsix, { + status: 200, + headers: { 'content-type': 'application/zip' }, + }); + }; + const out = await fetchJetBrainsPlugin({ xmlId: 'com.example' }); + assert.equal(out.source.type, 'jetbrains'); + assert.equal(out.source.xmlId, 'com.example'); + assert.equal(out.size, fakeVsix.length); + assert.ok(out.sha256); + assert.ok(out.sha256.length === 64); + } finally { + globalThis.fetch = origFetch; + } + }); + + it('resolves numericId → xmlId via metadata lookup, then downloads', async () => { + const origFetch = globalThis.fetch; + const calls = []; + const fakeVsix = Buffer.from('PK\x03\x04xx'); + try { + globalThis.fetch = async (url) => { + calls.push(String(url)); + if (String(url).includes('/api/plugins/7973')) { + return new Response(JSON.stringify({ xmlId: 'com.example.intellivue' }), { + status: 200, headers: { 'content-type': 'application/json' }, + }); + } + return new Response(fakeVsix, { status: 200 }); + }; + const out = await fetchJetBrainsPlugin({ numericId: '7973' }); + assert.equal(out.source.xmlId, 'com.example.intellivue'); + assert.equal(calls.length, 2); + assert.match(calls[0], /\/api\/plugins\/7973$/); + assert.match(calls[1], /plugin\/download\?pluginId=com\.example\.intellivue/); + } finally { + globalThis.fetch = origFetch; + } + }); + + it('rejects invalid numericId', async () => { + await assert.rejects( + () => fetchJetBrainsPlugin({ numericId: 'abc' }), + /invalid numericId/, + ); + }); + + it('rejects missing both xmlId and numericId', async () => { + await assert.rejects( + () => fetchJetBrainsPlugin({}), + /need xmlId or numericId/, + ); + }); + + it('rejects suspicious xmlId (shell-metachar)', async () => { + await assert.rejects( + () => fetchJetBrainsPlugin({ xmlId: 'evil;rm -rf' }), + /suspicious xmlId/, + ); + }); +}); + +describe('fetchPluginFromUrl — routes JetBrains vs VSIX', () => { + it('dispatches JetBrains URLs to fetchJetBrainsPlugin', async () => { + const origFetch = globalThis.fetch; + try { + globalThis.fetch = async () => new Response(Buffer.from('x'), { status: 200 }); + const out = await fetchPluginFromUrl('https://plugins.jetbrains.com/plugin/download?pluginId=com.example'); + assert.equal(out.source.type, 'jetbrains'); + } finally { + globalThis.fetch = origFetch; + } + }); + + it('dispatches OpenVSX URLs through the VSIX path (no regression)', async () => { + const origFetch = globalThis.fetch; + try { + // Minimal OpenVSX happy-path: version in URL → single download call. + globalThis.fetch = async () => new Response(Buffer.from('x'), { status: 200 }); + const out = await fetchPluginFromUrl('https://open-vsx.org/extension/redhat/java/1.29.0'); + assert.equal(out.source.type, 'openvsx'); + } finally { + globalThis.fetch = origFetch; + } + }); +});