feat(llm-security): /security ide-scan <url> — Marketplace/OpenVSX/direct VSIX (v6.4.0)
Pre-installation verification of VS Code extensions via URL — fetch a remote VSIX, extract it in a hardened sandbox, and run the existing IDE scanner pipeline against it. No npm dependencies. Sources: - VS Code Marketplace (publisher.gallery.vsassets.io direct download) - OpenVSX (open-vsx.org official API) - Direct .vsix HTTPS URLs Defenses: - HTTPS-only, TLS verified, manual redirect with per-source host whitelist - 30s total timeout via AbortController - 50MB compressed cap, 500MB uncompressed, 100x expansion ratio - Zero-dep ZIP extractor: zip-slip, absolute paths, drive letters, NUL bytes, symlinks (Unix mode 0xA000), depth limits, ZIP64 rejected, encrypted rejected - SHA-256 streamed during fetch, surfaced in meta.source - Temp dir cleanup in all paths (try/finally) Files: - scanners/lib/vsix-fetch.mjs (HTTPS fetcher, host whitelist, streaming SHA-256) - scanners/lib/zip-extract.mjs (zero-dep parser with hardening caps) - knowledge/marketplace-api-notes.md (endpoint reference) - 3 test files (48 tests added: vsix-fetch, zip-extract, ide-extension-url) Tests: 1296 → 1344 (all green). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
6252e55700
commit
fe0193956d
16 changed files with 1543 additions and 22 deletions
97
plugins/llm-security/tests/lib/build-zip.mjs
Normal file
97
plugins/llm-security/tests/lib/build-zip.mjs
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// build-zip.mjs — Minimal synthetic ZIP builder for tests.
|
||||
// Supports STORE method only. Lets tests construct adversarial archives that
|
||||
// real zip tools refuse to emit (zip-slip names, symlink mode bits, oversized
|
||||
// uncompressed sizes for bomb tests).
|
||||
|
||||
import { crc32 } from 'node:zlib';
|
||||
|
||||
const SIG_LFH = 0x04034b50;
|
||||
const SIG_CD = 0x02014b50;
|
||||
const SIG_EOCD = 0x06054b50;
|
||||
|
||||
function crc(buf) {
|
||||
return crc32(buf) >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a ZIP buffer from a list of entries.
|
||||
* @param {Array<{ name: string, data: Buffer|string, externalAttr?: number, versionMadeBy?: number, declaredUncompSize?: number, declaredCompSize?: number }>} entries
|
||||
* @returns {Buffer}
|
||||
*/
|
||||
export function buildZip(entries) {
|
||||
const lfhParts = [];
|
||||
const cdParts = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
const nameBuf = Buffer.from(entry.name, 'utf8');
|
||||
const data = Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(entry.data || '', 'utf8');
|
||||
const compSize = entry.declaredCompSize ?? data.length;
|
||||
const uncompSize = entry.declaredUncompSize ?? data.length;
|
||||
const c = crc(data);
|
||||
|
||||
// Local file header (30 bytes)
|
||||
const lfh = Buffer.alloc(30);
|
||||
lfh.writeUInt32LE(SIG_LFH, 0);
|
||||
lfh.writeUInt16LE(20, 4); // version needed
|
||||
lfh.writeUInt16LE(0, 6); // flags
|
||||
lfh.writeUInt16LE(0, 8); // method = STORE
|
||||
lfh.writeUInt16LE(0, 10); // time
|
||||
lfh.writeUInt16LE(0, 12); // date
|
||||
lfh.writeUInt32LE(c, 14); // crc32
|
||||
lfh.writeUInt32LE(compSize, 18); // compressed size
|
||||
lfh.writeUInt32LE(uncompSize, 22); // uncompressed size
|
||||
lfh.writeUInt16LE(nameBuf.length, 26);
|
||||
lfh.writeUInt16LE(0, 28); // extra len
|
||||
|
||||
lfhParts.push(lfh, nameBuf, data);
|
||||
const thisLfhOffset = offset;
|
||||
offset += lfh.length + nameBuf.length + data.length;
|
||||
|
||||
// Central directory header (46 bytes)
|
||||
const cd = Buffer.alloc(46);
|
||||
cd.writeUInt32LE(SIG_CD, 0);
|
||||
cd.writeUInt16LE(entry.versionMadeBy ?? (3 << 8) | 20, 4); // OS=Unix(3), version=20
|
||||
cd.writeUInt16LE(20, 6);
|
||||
cd.writeUInt16LE(0, 8);
|
||||
cd.writeUInt16LE(0, 10);
|
||||
cd.writeUInt16LE(0, 12);
|
||||
cd.writeUInt16LE(0, 14);
|
||||
cd.writeUInt32LE(c, 16);
|
||||
cd.writeUInt32LE(compSize, 20);
|
||||
cd.writeUInt32LE(uncompSize, 24);
|
||||
cd.writeUInt16LE(nameBuf.length, 28);
|
||||
cd.writeUInt16LE(0, 30);
|
||||
cd.writeUInt16LE(0, 32); // comment len
|
||||
cd.writeUInt16LE(0, 34); // disk start
|
||||
cd.writeUInt16LE(0, 36); // internal attrs
|
||||
cd.writeUInt32LE((entry.externalAttr ?? 0) >>> 0, 38); // external attrs (unsigned)
|
||||
cd.writeUInt32LE(thisLfhOffset, 42);
|
||||
|
||||
cdParts.push(cd, nameBuf);
|
||||
}
|
||||
|
||||
const lfhSection = Buffer.concat(lfhParts);
|
||||
const cdSection = Buffer.concat(cdParts);
|
||||
const cdOffset = lfhSection.length;
|
||||
const cdSize = cdSection.length;
|
||||
|
||||
const eocd = Buffer.alloc(22);
|
||||
eocd.writeUInt32LE(SIG_EOCD, 0);
|
||||
eocd.writeUInt16LE(0, 4);
|
||||
eocd.writeUInt16LE(0, 6);
|
||||
eocd.writeUInt16LE(entries.length, 8);
|
||||
eocd.writeUInt16LE(entries.length, 10);
|
||||
eocd.writeUInt32LE(cdSize, 12);
|
||||
eocd.writeUInt32LE(cdOffset, 16);
|
||||
eocd.writeUInt16LE(0, 20);
|
||||
|
||||
return Buffer.concat([lfhSection, cdSection, eocd]);
|
||||
}
|
||||
|
||||
/** Convenience: produce a unix mode in the upper 16 bits of externalAttr. */
|
||||
export function unixModeAttr(mode) {
|
||||
return (mode & 0xFFFF) << 16;
|
||||
}
|
||||
|
||||
export const MODE_SYMLINK = 0xA1FF; // S_IFLNK | rwxrwxrwx
|
||||
145
plugins/llm-security/tests/scanners/ide-extension-url.test.mjs
Normal file
145
plugins/llm-security/tests/scanners/ide-extension-url.test.mjs
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
// ide-extension-url.test.mjs — Integration tests for `/security ide-scan <url>`.
|
||||
// Mocks global.fetch so we never hit real Marketplace / OpenVSX endpoints.
|
||||
|
||||
import { describe, it, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { resetCounter } from '../../scanners/lib/output.mjs';
|
||||
import { scan } from '../../scanners/ide-extension-scanner.mjs';
|
||||
import { buildZip } from '../lib/build-zip.mjs';
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
|
||||
function mockResponse(buffer, { status = 200 } = {}) {
|
||||
const stream = new ReadableStream({
|
||||
start(controller) { controller.enqueue(buffer); controller.close(); },
|
||||
});
|
||||
return new Response(stream, { status, headers: { 'content-type': 'application/octet-stream' } });
|
||||
}
|
||||
|
||||
function jsonResponse(obj) {
|
||||
return new Response(JSON.stringify(obj), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function buildBenignVsix() {
|
||||
const pkg = JSON.stringify({
|
||||
publisher: 'anthropic',
|
||||
name: 'claude-code',
|
||||
version: '1.0.0',
|
||||
engines: { vscode: '^1.80.0' },
|
||||
main: './extension.js',
|
||||
activationEvents: ['onCommand:claude.hello'],
|
||||
categories: ['Other'],
|
||||
});
|
||||
return buildZip([
|
||||
{ name: 'extension.vsixmanifest', data: '<PackageManifest></PackageManifest>' },
|
||||
{ name: 'extension/package.json', data: pkg },
|
||||
{ name: 'extension/extension.js', data: 'module.exports = { activate(){} };' },
|
||||
]);
|
||||
}
|
||||
|
||||
function installFetchRouter(routes) {
|
||||
globalThis.fetch = async (url) => {
|
||||
const handler = routes(url);
|
||||
if (!handler) throw new Error(`unrouted fetch: ${url}`);
|
||||
return handler;
|
||||
};
|
||||
}
|
||||
|
||||
describe('ide-extension-scanner — URL mode', () => {
|
||||
before(() => resetCounter());
|
||||
after(() => { globalThis.fetch = realFetch; });
|
||||
|
||||
it('rejects unsupported URL with a warning, no extensions scanned', async () => {
|
||||
installFetchRouter(() => null);
|
||||
const env = await scan('https://example.com/random.zip', { vscodeOnly: true });
|
||||
assert.equal(env.extensions.length, 0);
|
||||
assert.ok(env.meta.warnings.some(w => /unsupported URL/i.test(w)));
|
||||
assert.equal(env.meta.source, null);
|
||||
});
|
||||
|
||||
it('reports github URL as unsupported in v6.4.0', async () => {
|
||||
installFetchRouter(() => null);
|
||||
const env = await scan('https://github.com/anthropic/claude-code', { vscodeOnly: true });
|
||||
assert.equal(env.extensions.length, 0);
|
||||
assert.ok(env.meta.warnings.some(w => /GitHub repo URLs/i.test(w)));
|
||||
});
|
||||
|
||||
it('fetches OpenVSX VSIX and scans the extracted extension', async () => {
|
||||
const vsix = buildBenignVsix();
|
||||
let metaCalled = false;
|
||||
let downloadCalled = false;
|
||||
installFetchRouter((url) => {
|
||||
if (url.endsWith('/latest')) {
|
||||
metaCalled = true;
|
||||
return jsonResponse({ version: '1.0.0' });
|
||||
}
|
||||
if (url.includes('/file/') && url.endsWith('.vsix')) {
|
||||
downloadCalled = true;
|
||||
return mockResponse(vsix);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const env = await scan('https://open-vsx.org/extension/anthropic/claude-code', { vscodeOnly: true });
|
||||
assert.ok(metaCalled, 'expected metadata fetch for latest version');
|
||||
assert.ok(downloadCalled, 'expected VSIX download');
|
||||
assert.equal(env.extensions.length, 1);
|
||||
assert.equal(env.extensions[0].id, 'anthropic.claude-code');
|
||||
assert.equal(env.extensions[0].version, '1.0.0');
|
||||
assert.ok(env.meta.source);
|
||||
assert.equal(env.meta.source.type, 'url');
|
||||
assert.equal(env.meta.source.publisher, 'anthropic');
|
||||
assert.equal(env.meta.source.name, 'claude-code');
|
||||
assert.equal(env.meta.source.version, '1.0.0');
|
||||
assert.match(env.meta.source.sha256, /^[a-f0-9]{64}$/);
|
||||
assert.equal(env.meta.target, 'https://open-vsx.org/extension/anthropic/claude-code');
|
||||
});
|
||||
|
||||
it('fetches Marketplace VSIX directly without metadata round-trip', async () => {
|
||||
const vsix = buildBenignVsix();
|
||||
let downloads = 0;
|
||||
installFetchRouter((url) => {
|
||||
if (url.includes('Microsoft.VisualStudio.Services.VSIXPackage')) {
|
||||
downloads++;
|
||||
return mockResponse(vsix);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const env = await scan('https://marketplace.visualstudio.com/items?itemName=anthropic.claude-code', { vscodeOnly: true });
|
||||
assert.equal(downloads, 1);
|
||||
assert.equal(env.extensions.length, 1);
|
||||
assert.equal(env.extensions[0].id, 'anthropic.claude-code');
|
||||
assert.equal(env.meta.source.type, 'url');
|
||||
assert.equal(env.meta.source.requestedUrl?.includes('VSIXPackage'), true);
|
||||
});
|
||||
|
||||
it('cleans up temp dir even when extraction fails', async () => {
|
||||
// Return a non-zip body so extract throws.
|
||||
installFetchRouter(() => mockResponse(Buffer.from('not a zip at all')));
|
||||
const env = await scan('https://example.com/bad.vsix', { vscodeOnly: true });
|
||||
assert.equal(env.extensions.length, 0);
|
||||
assert.ok(env.meta.warnings.some(w => /malformed VSIX/.test(w)));
|
||||
});
|
||||
|
||||
it('rejects zip-slip VSIX as malformed', async () => {
|
||||
const evil = buildZip([
|
||||
{ name: 'extension/package.json', data: '{}' },
|
||||
{ name: '../escape.txt', data: 'pwned' },
|
||||
]);
|
||||
installFetchRouter(() => mockResponse(evil));
|
||||
const env = await scan('https://example.com/evil.vsix', { vscodeOnly: true });
|
||||
assert.equal(env.extensions.length, 0);
|
||||
assert.ok(env.meta.warnings.some(w => /malformed VSIX/.test(w) && /traversal/.test(w)));
|
||||
});
|
||||
|
||||
it('handles fetch network failure cleanly', async () => {
|
||||
installFetchRouter(() => { throw new Error('ECONNREFUSED'); });
|
||||
const env = await scan('https://open-vsx.org/extension/foo/bar', { vscodeOnly: true });
|
||||
assert.equal(env.extensions.length, 0);
|
||||
assert.ok(env.meta.warnings.some(w => /URL fetch\/extract failed/.test(w)));
|
||||
});
|
||||
});
|
||||
126
plugins/llm-security/tests/scanners/vsix-fetch.test.mjs
Normal file
126
plugins/llm-security/tests/scanners/vsix-fetch.test.mjs
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
// vsix-fetch.test.mjs — Unit tests for URL detection + body capping.
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { detectUrlType, __testing } from '../../scanners/lib/vsix-fetch.mjs';
|
||||
|
||||
const { isAllowedHost, readBodyCapped, MAX_VSIX_BYTES } = __testing;
|
||||
|
||||
describe('detectUrlType', () => {
|
||||
it('detects VS Code Marketplace URL', () => {
|
||||
const out = detectUrlType('https://marketplace.visualstudio.com/items?itemName=ms-python.python');
|
||||
assert.equal(out.type, 'marketplace');
|
||||
assert.equal(out.publisher, 'ms-python');
|
||||
assert.equal(out.name, 'python');
|
||||
});
|
||||
|
||||
it('returns unknown for marketplace URL without itemName', () => {
|
||||
const out = detectUrlType('https://marketplace.visualstudio.com/items');
|
||||
assert.equal(out.type, 'unknown');
|
||||
});
|
||||
|
||||
it('returns unknown for marketplace itemName without dot', () => {
|
||||
const out = detectUrlType('https://marketplace.visualstudio.com/items?itemName=foobar');
|
||||
assert.equal(out.type, 'unknown');
|
||||
});
|
||||
|
||||
it('detects OpenVSX URL with version', () => {
|
||||
const out = detectUrlType('https://open-vsx.org/extension/anthropic/claude-code/1.2.3');
|
||||
assert.equal(out.type, 'openvsx');
|
||||
assert.equal(out.publisher, 'anthropic');
|
||||
assert.equal(out.name, 'claude-code');
|
||||
assert.equal(out.version, '1.2.3');
|
||||
});
|
||||
|
||||
it('detects OpenVSX URL without version', () => {
|
||||
const out = detectUrlType('https://open-vsx.org/extension/anthropic/claude-code');
|
||||
assert.equal(out.type, 'openvsx');
|
||||
assert.equal(out.publisher, 'anthropic');
|
||||
assert.equal(out.name, 'claude-code');
|
||||
assert.equal(out.version, null);
|
||||
});
|
||||
|
||||
it('detects direct .vsix download', () => {
|
||||
const out = detectUrlType('https://example.com/path/extension.vsix');
|
||||
assert.equal(out.type, 'vsix');
|
||||
});
|
||||
|
||||
it('detects GitHub URL as github (unsupported)', () => {
|
||||
const out = detectUrlType('https://github.com/anthropic/claude-code');
|
||||
assert.equal(out.type, 'github');
|
||||
});
|
||||
|
||||
it('rejects plain HTTP', () => {
|
||||
const out = detectUrlType('http://marketplace.visualstudio.com/items?itemName=ms-python.python');
|
||||
assert.equal(out.type, 'unknown');
|
||||
});
|
||||
|
||||
it('returns unknown for malformed URL', () => {
|
||||
const out = detectUrlType('not a url');
|
||||
assert.equal(out.type, 'unknown');
|
||||
});
|
||||
|
||||
it('returns unknown for unrelated HTTPS URL', () => {
|
||||
const out = detectUrlType('https://example.com/somefile.zip');
|
||||
assert.equal(out.type, 'unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAllowedHost', () => {
|
||||
it('allows marketplace gallery cdn for marketplace fetches', () => {
|
||||
assert.equal(isAllowedHost('foo.gallerycdn.vsassets.io', 'marketplace'), true);
|
||||
assert.equal(isAllowedHost('marketplace.visualstudio.com', 'marketplace'), true);
|
||||
});
|
||||
|
||||
it('rejects unrelated host for marketplace fetches', () => {
|
||||
assert.equal(isAllowedHost('evil.example.com', 'marketplace'), false);
|
||||
});
|
||||
|
||||
it('allows openvsx blob storage', () => {
|
||||
assert.equal(isAllowedHost('open-vsx.org', 'openvsx'), true);
|
||||
assert.equal(isAllowedHost('openvsxorg.blob.core.windows.net', 'openvsx'), true);
|
||||
});
|
||||
|
||||
it('rejects unrelated host for openvsx fetches', () => {
|
||||
assert.equal(isAllowedHost('evil.example.com', 'openvsx'), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readBodyCapped', () => {
|
||||
function makeStreamResponse(chunks) {
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) controller.enqueue(chunk);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return new Response(stream);
|
||||
}
|
||||
|
||||
it('reads small body fully and computes SHA-256', async () => {
|
||||
const data = new TextEncoder().encode('hello world');
|
||||
const res = makeStreamResponse([data]);
|
||||
const ctrl = new AbortController();
|
||||
const out = await readBodyCapped(res, ctrl);
|
||||
assert.equal(out.size, 11);
|
||||
assert.equal(out.buffer.toString('utf8'), 'hello world');
|
||||
// sha256("hello world")
|
||||
assert.equal(out.sha256, 'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9');
|
||||
});
|
||||
|
||||
it('aborts when body exceeds MAX_VSIX_BYTES', async () => {
|
||||
// Stream a small chunk repeated such that total > cap.
|
||||
const chunkSize = 1024 * 1024;
|
||||
const chunk = new Uint8Array(chunkSize);
|
||||
const totalChunks = Math.ceil(MAX_VSIX_BYTES / chunkSize) + 2; // overshoot
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
for (let i = 0; i < totalChunks; i++) controller.enqueue(chunk);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const res = new Response(stream);
|
||||
const ctrl = new AbortController();
|
||||
await assert.rejects(() => readBodyCapped(res, ctrl), /exceeds maximum size/);
|
||||
});
|
||||
});
|
||||
267
plugins/llm-security/tests/scanners/zip-extract.test.mjs
Normal file
267
plugins/llm-security/tests/scanners/zip-extract.test.mjs
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
// zip-extract.test.mjs — Unit tests for the zero-dep ZIP extractor.
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm, readFile, readdir } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { deflateRawSync } from 'node:zlib';
|
||||
import { extractToDir, listEntries, ZipError, __testing } from '../../scanners/lib/zip-extract.mjs';
|
||||
import { buildZip, unixModeAttr, MODE_SYMLINK } from '../lib/build-zip.mjs';
|
||||
|
||||
const { validateEntryName, isSymlink, DEFAULT_CAPS } = __testing;
|
||||
|
||||
async function withTempDir(fn) {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'zip-test-'));
|
||||
try { return await fn(dir); }
|
||||
finally { await rm(dir, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
describe('validateEntryName', () => {
|
||||
it('accepts a normal nested path', () => {
|
||||
const out = validateEntryName('extension/package.json', DEFAULT_CAPS);
|
||||
assert.ok(out && out.includes('package.json'));
|
||||
});
|
||||
it('returns null for directory entries', () => {
|
||||
assert.equal(validateEntryName('extension/', DEFAULT_CAPS), null);
|
||||
});
|
||||
it('rejects parent traversal', () => {
|
||||
assert.throws(() => validateEntryName('../etc/passwd', DEFAULT_CAPS), /traversal/);
|
||||
});
|
||||
it('rejects deep parent traversal', () => {
|
||||
assert.throws(() => validateEntryName('extension/../../escape', DEFAULT_CAPS), /traversal/);
|
||||
});
|
||||
it('rejects POSIX absolute paths', () => {
|
||||
assert.throws(() => validateEntryName('/etc/passwd', DEFAULT_CAPS), /absolute/);
|
||||
});
|
||||
it('rejects Windows drive letters', () => {
|
||||
assert.throws(() => validateEntryName('C:\\Windows\\sys', DEFAULT_CAPS), /drive-letter|absolute/);
|
||||
});
|
||||
it('rejects backslash absolute paths', () => {
|
||||
assert.throws(() => validateEntryName('\\foo', DEFAULT_CAPS), /absolute/);
|
||||
});
|
||||
it('rejects NUL bytes', () => {
|
||||
assert.throws(() => validateEntryName('foo\u0000bar', DEFAULT_CAPS), /NUL/);
|
||||
});
|
||||
it('rejects empty entry names', () => {
|
||||
assert.throws(() => validateEntryName('', DEFAULT_CAPS), /empty/);
|
||||
});
|
||||
it('rejects very deep paths beyond depth cap', () => {
|
||||
const deep = Array.from({ length: 25 }, () => 'a').join('/');
|
||||
assert.throws(() => validateEntryName(deep, { ...DEFAULT_CAPS, maxDepth: 20 }), /depth/);
|
||||
});
|
||||
it('normalizes backslashes in path', () => {
|
||||
const out = validateEntryName('extension\\sub\\file.txt', DEFAULT_CAPS);
|
||||
assert.ok(out && (out.includes('sub') || out.includes('file.txt')));
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSymlink', () => {
|
||||
it('detects unix-made symlink mode bits', () => {
|
||||
const entry = { versionMadeBy: (3 << 8) | 20, externalAttr: unixModeAttr(MODE_SYMLINK) };
|
||||
assert.equal(isSymlink(entry), true);
|
||||
});
|
||||
it('ignores mode bits when versionMadeBy os != Unix', () => {
|
||||
const entry = { versionMadeBy: (0 << 8) | 20, externalAttr: unixModeAttr(MODE_SYMLINK) };
|
||||
assert.equal(isSymlink(entry), false);
|
||||
});
|
||||
it('returns false for regular file', () => {
|
||||
const entry = { versionMadeBy: (3 << 8) | 20, externalAttr: unixModeAttr(0x81A4) };
|
||||
assert.equal(isSymlink(entry), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractToDir — happy path', () => {
|
||||
it('extracts a small ZIP with a nested file', async () => {
|
||||
const buf = buildZip([
|
||||
{ name: 'extension/package.json', data: '{"hello":"world"}' },
|
||||
{ name: 'extension/extension.js', data: 'console.log(1)' },
|
||||
]);
|
||||
await withTempDir(async (dir) => {
|
||||
const r = await extractToDir(buf, dir);
|
||||
assert.equal(r.entries, 2);
|
||||
const pkg = await readFile(join(dir, 'extension/package.json'), 'utf8');
|
||||
assert.match(pkg, /hello/);
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts deflate-compressed entries', async () => {
|
||||
// Pseudo-random bytes so compression ratio stays well under the cap.
|
||||
const original = Buffer.alloc(2000);
|
||||
for (let i = 0; i < original.length; i++) original[i] = (i * 73 + 11) & 0xFF;
|
||||
const compressed = deflateRawSync(original);
|
||||
// Manually construct a buildZip-style entry but with method=8 + compSize set.
|
||||
// buildZip only supports STORE; we need a small bespoke builder for this test.
|
||||
// Use raw buildZip + override method by patching after.
|
||||
// Simpler: assert listEntries handles a deflate one we craft.
|
||||
// Construct manually:
|
||||
const nameBuf = Buffer.from('extension/big.txt', 'utf8');
|
||||
const lfh = Buffer.alloc(30);
|
||||
lfh.writeUInt32LE(0x04034b50, 0);
|
||||
lfh.writeUInt16LE(20, 4);
|
||||
lfh.writeUInt16LE(0, 6);
|
||||
lfh.writeUInt16LE(8, 8); // DEFLATE
|
||||
lfh.writeUInt32LE(0, 14); // CRC unused (we don't validate)
|
||||
lfh.writeUInt32LE(compressed.length, 18);
|
||||
lfh.writeUInt32LE(original.length, 22);
|
||||
lfh.writeUInt16LE(nameBuf.length, 26);
|
||||
lfh.writeUInt16LE(0, 28);
|
||||
const cd = Buffer.alloc(46);
|
||||
cd.writeUInt32LE(0x02014b50, 0);
|
||||
cd.writeUInt16LE(20, 4);
|
||||
cd.writeUInt16LE(20, 6);
|
||||
cd.writeUInt16LE(0, 8);
|
||||
cd.writeUInt16LE(8, 10); // DEFLATE
|
||||
cd.writeUInt32LE(0, 16);
|
||||
cd.writeUInt32LE(compressed.length, 20);
|
||||
cd.writeUInt32LE(original.length, 24);
|
||||
cd.writeUInt16LE(nameBuf.length, 28);
|
||||
cd.writeUInt32LE(0, 38);
|
||||
cd.writeUInt32LE(0, 42); // LFH at offset 0
|
||||
const eocd = Buffer.alloc(22);
|
||||
eocd.writeUInt32LE(0x06054b50, 0);
|
||||
eocd.writeUInt16LE(1, 8);
|
||||
eocd.writeUInt16LE(1, 10);
|
||||
eocd.writeUInt32LE(46 + nameBuf.length, 12);
|
||||
eocd.writeUInt32LE(30 + nameBuf.length + compressed.length, 16);
|
||||
const buf = Buffer.concat([lfh, nameBuf, compressed, cd, nameBuf, eocd]);
|
||||
|
||||
await withTempDir(async (dir) => {
|
||||
const r = await extractToDir(buf, dir);
|
||||
assert.equal(r.entries, 1);
|
||||
const out = await readFile(join(dir, 'extension/big.txt'));
|
||||
assert.equal(out.length, original.length);
|
||||
assert.equal(out.toString('utf8'), original.toString('utf8'));
|
||||
});
|
||||
});
|
||||
|
||||
it('lists entries without extracting', () => {
|
||||
const buf = buildZip([{ name: 'a.txt', data: 'x' }, { name: 'b.txt', data: 'yy' }]);
|
||||
const out = listEntries(buf);
|
||||
assert.equal(out.length, 2);
|
||||
assert.equal(out[0].name, 'a.txt');
|
||||
assert.equal(out[1].uncompSize, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractToDir — adversarial', () => {
|
||||
it('rejects zip-slip via parent traversal', async () => {
|
||||
const buf = buildZip([{ name: '../escape.txt', data: 'pwned' }]);
|
||||
await withTempDir(async (dir) => {
|
||||
await assert.rejects(() => extractToDir(buf, dir), /traversal/);
|
||||
const items = await readdir(dir);
|
||||
assert.equal(items.length, 0, 'no files should have been written');
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects zip-slip via absolute POSIX path', async () => {
|
||||
const buf = buildZip([{ name: '/tmp/leak.txt', data: 'pwned' }]);
|
||||
await withTempDir(async (dir) => {
|
||||
await assert.rejects(() => extractToDir(buf, dir), /absolute|traversal/);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects symlink entries', async () => {
|
||||
const buf = buildZip([{
|
||||
name: 'evil-link',
|
||||
data: '../../etc/passwd',
|
||||
versionMadeBy: (3 << 8) | 20,
|
||||
externalAttr: unixModeAttr(MODE_SYMLINK),
|
||||
}]);
|
||||
await withTempDir(async (dir) => {
|
||||
await assert.rejects(() => extractToDir(buf, dir), /symlink/);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects entries beyond maxEntries cap', async () => {
|
||||
const entries = Array.from({ length: 5 }, (_, i) => ({ name: `f${i}.txt`, data: 'x' }));
|
||||
const buf = buildZip(entries);
|
||||
await withTempDir(async (dir) => {
|
||||
await assert.rejects(
|
||||
() => extractToDir(buf, dir, { caps: { ...DEFAULT_CAPS, maxEntries: 3 } }),
|
||||
/too many/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects zip-bomb: STORED entry exceeding maxUncompressedBytes', async () => {
|
||||
const buf = buildZip([{
|
||||
name: 'bomb.txt',
|
||||
data: Buffer.alloc(2000),
|
||||
declaredUncompSize: 2000,
|
||||
}]);
|
||||
await withTempDir(async (dir) => {
|
||||
await assert.rejects(
|
||||
() => extractToDir(buf, dir, { caps: { ...DEFAULT_CAPS, maxUncompressedBytes: 1000 } }),
|
||||
/maxUncompressedBytes/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects zip-bomb: deflate expansion ratio exceeds cap', async () => {
|
||||
// Build an entry with high uncompressed and tiny compressed claim.
|
||||
const original = Buffer.alloc(20_000); // 20KB of zeros — compresses tiny
|
||||
const compressed = deflateRawSync(original);
|
||||
const nameBuf = Buffer.from('bomb.bin', 'utf8');
|
||||
const lfh = Buffer.alloc(30);
|
||||
lfh.writeUInt32LE(0x04034b50, 0);
|
||||
lfh.writeUInt16LE(20, 4);
|
||||
lfh.writeUInt16LE(8, 8);
|
||||
lfh.writeUInt32LE(compressed.length, 18);
|
||||
lfh.writeUInt32LE(original.length, 22);
|
||||
lfh.writeUInt16LE(nameBuf.length, 26);
|
||||
const cd = Buffer.alloc(46);
|
||||
cd.writeUInt32LE(0x02014b50, 0);
|
||||
cd.writeUInt16LE(20, 4); cd.writeUInt16LE(20, 6); cd.writeUInt16LE(8, 10);
|
||||
cd.writeUInt32LE(compressed.length, 20);
|
||||
cd.writeUInt32LE(original.length, 24);
|
||||
cd.writeUInt16LE(nameBuf.length, 28);
|
||||
cd.writeUInt32LE(0, 42);
|
||||
const eocd = Buffer.alloc(22);
|
||||
eocd.writeUInt32LE(0x06054b50, 0);
|
||||
eocd.writeUInt16LE(1, 8); eocd.writeUInt16LE(1, 10);
|
||||
eocd.writeUInt32LE(46 + nameBuf.length, 12);
|
||||
eocd.writeUInt32LE(30 + nameBuf.length + compressed.length, 16);
|
||||
const buf = Buffer.concat([lfh, nameBuf, compressed, cd, nameBuf, eocd]);
|
||||
await withTempDir(async (dir) => {
|
||||
await assert.rejects(
|
||||
() => extractToDir(buf, dir, { caps: { ...DEFAULT_CAPS, maxExpansionRatio: 5 } }),
|
||||
/expansion ratio|exceeds/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unknown compression methods', async () => {
|
||||
// Manually craft an entry with method=6 (Implode, unsupported)
|
||||
const nameBuf = Buffer.from('weird.bin', 'utf8');
|
||||
const data = Buffer.from('x');
|
||||
const lfh = Buffer.alloc(30);
|
||||
lfh.writeUInt32LE(0x04034b50, 0);
|
||||
lfh.writeUInt16LE(6, 8); // method=Implode
|
||||
lfh.writeUInt32LE(data.length, 18);
|
||||
lfh.writeUInt32LE(data.length, 22);
|
||||
lfh.writeUInt16LE(nameBuf.length, 26);
|
||||
const cd = Buffer.alloc(46);
|
||||
cd.writeUInt32LE(0x02014b50, 0);
|
||||
cd.writeUInt16LE(6, 10);
|
||||
cd.writeUInt32LE(data.length, 20);
|
||||
cd.writeUInt32LE(data.length, 24);
|
||||
cd.writeUInt16LE(nameBuf.length, 28);
|
||||
const eocd = Buffer.alloc(22);
|
||||
eocd.writeUInt32LE(0x06054b50, 0);
|
||||
eocd.writeUInt16LE(1, 8); eocd.writeUInt16LE(1, 10);
|
||||
eocd.writeUInt32LE(46 + nameBuf.length, 12);
|
||||
eocd.writeUInt32LE(30 + nameBuf.length + data.length, 16);
|
||||
const buf = Buffer.concat([lfh, nameBuf, data, cd, nameBuf, eocd]);
|
||||
await withTempDir(async (dir) => {
|
||||
await assert.rejects(() => extractToDir(buf, dir), /unsupported compression/);
|
||||
});
|
||||
});
|
||||
|
||||
it('throws ZipError when EOCD is missing', async () => {
|
||||
const garbage = Buffer.from('not a zip file at all');
|
||||
await withTempDir(async (dir) => {
|
||||
await assert.rejects(() => extractToDir(garbage, dir), /EOCD/);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue