feat(llm-security): add fetchJetBrainsPlugin + URL detection for plugins.jetbrains.com
This commit is contained in:
parent
112cb5af45
commit
23455e5a66
2 changed files with 315 additions and 2 deletions
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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/<numericId>-<slug>', () => {
|
||||
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/<numericId> 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=<xmlId>', () => {
|
||||
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=<xmlId>&version=<v>', () => {
|
||||
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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue