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

@ -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;
}
});
});