mirror of
https://github.com/nodejs/node.git
synced 2026-11-09 15:11:11 -05:00
Add ZIP archive support to the node:zlib module through three classes and a set of helpers: - ZipEntry: a single archive member, with buffered reads (content()), bounded-memory streaming reads (contentIterator()), and create()/createStream() for building members. - ZipFile: random access to an archive backed by a file descriptor, reading members lazily without retaining their content and writing new members in place; opened with open()/openSync(). - ZipBuffer: a zero-copy, in-memory view over an archive already held in a Buffer. createZipArchive() serializes a sequence of entries into an archive byte stream, and setMaxZipContentSize() bounds the default in-memory decompression size. Every operation has both an asynchronous and a synchronous form. Signed-off-by: Philipp Dunkel <pip@pipobscure.com> PR-URL: https://github.com/nodejs/node/pull/64339 Reviewed-By: Matteo Collina <matteo.collina@gmail.com> Reviewed-By: James M Snell <jasnell@gmail.com> Reviewed-By: Marco Ippolito <marcoippolito54@gmail.com> Reviewed-By: Aviv Keller <me@aviv.sh>
266 lines
11 KiB
JavaScript
266 lines
11 KiB
JavaScript
'use strict';
|
|
|
|
// Regression tests for the security review of the ZIP archive API. Each test
|
|
// encodes the *desired* safe behaviour; on the pre-fix code it fails, exposing
|
|
// the issue. See the review for context.
|
|
|
|
const common = require('../common');
|
|
|
|
const assert = require('node:assert');
|
|
const zlib = require('node:zlib');
|
|
const fs = require('node:fs');
|
|
const fsp = require('node:fs/promises');
|
|
const path = require('node:path');
|
|
const tmpdir = require('../common/tmpdir');
|
|
const { test } = require('node:test');
|
|
|
|
tmpdir.refresh();
|
|
|
|
let seq = 0;
|
|
async function tempZip(entries) {
|
|
const dir = await fsp.mkdtemp(path.join(tmpdir.path, `zip-sec-${seq++}-`));
|
|
const chunks = [];
|
|
for await (const chunk of zlib.createZipArchive(entries)) chunks.push(chunk);
|
|
const filePath = path.join(dir, 'archive.zip');
|
|
await fsp.writeFile(filePath, Buffer.concat(chunks));
|
|
return { dir, filePath, cleanup: () => fsp.rm(dir, { recursive: true, force: true }) };
|
|
}
|
|
|
|
// -- Finding 1: no closed-state guard; operations after close() fall through
|
|
// to a raw (possibly reused) file descriptor. -------------------------------
|
|
|
|
test('post-close operations are rejected cleanly, not via a raw fd', async () => {
|
|
const { dir, filePath, cleanup } = await tempZip(
|
|
[await zlib.ZipEntry.create('a.txt', Buffer.from('secret-archive-data'))]);
|
|
try {
|
|
const zip = await zlib.ZipFile.open(filePath, { writable: true });
|
|
const retained = await zip.get('a.txt');
|
|
await zip.close();
|
|
|
|
// Encourage descriptor reuse: open an unrelated "victim" file right after
|
|
// close(), which typically reclaims the just-freed fd number.
|
|
const victimPath = path.join(dir, 'victim.txt');
|
|
const VICTIM = 'do-not-touch-this-victim-file';
|
|
await fsp.writeFile(victimPath, VICTIM);
|
|
const victimFd = fs.openSync(victimPath, 'r+');
|
|
try {
|
|
// A retained entry must not read through the (reused) descriptor.
|
|
await assert.rejects(retained.content(), { code: 'ERR_INVALID_STATE' });
|
|
// A post-close mutation must not truncate/overwrite whatever now owns
|
|
// the fd number.
|
|
await assert.rejects(
|
|
zip.addEntry(await zlib.ZipEntry.create('b.txt', Buffer.from('x'))),
|
|
{ code: 'ERR_INVALID_STATE' });
|
|
// A second close() must be a safe no-op, not a double-close of a reused fd.
|
|
await zip.close();
|
|
// The victim file must be byte-for-byte intact.
|
|
assert.strictEqual(fs.readFileSync(victimPath, 'utf8'), VICTIM);
|
|
} finally {
|
|
fs.closeSync(victimFd);
|
|
}
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
test('post-close operations are rejected cleanly (sync)', async () => {
|
|
const { dir, filePath, cleanup } = await tempZip(
|
|
[await zlib.ZipEntry.create('a.txt', Buffer.from('secret-archive-data'))]);
|
|
try {
|
|
const zip = zlib.ZipFile.openSync(filePath, { writable: true });
|
|
const retained = zip.getSync('a.txt');
|
|
zip.closeSync();
|
|
|
|
const victimPath = path.join(dir, 'victim.txt');
|
|
const VICTIM = 'do-not-touch-this-victim-file';
|
|
fs.writeFileSync(victimPath, VICTIM);
|
|
const victimFd = fs.openSync(victimPath, 'r+');
|
|
try {
|
|
assert.throws(() => retained.contentSync(), { code: 'ERR_INVALID_STATE' });
|
|
assert.throws(
|
|
() => zip.addEntrySync(zlib.ZipEntry.createSync('b.txt', Buffer.from('x'))),
|
|
{ code: 'ERR_INVALID_STATE' });
|
|
// A second closeSync() must be a safe no-op, not EBADF on a reused fd.
|
|
zip.closeSync();
|
|
assert.strictEqual(fs.readFileSync(victimPath, 'utf8'), VICTIM);
|
|
} finally {
|
|
fs.closeSync(victimFd);
|
|
}
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
// -- Finding 2: add() awaits ZipEntry.create() before registering the mutation,
|
|
// so a synchronous close() can slip in and close the fd underneath it. -------
|
|
|
|
test('closeSync() while an async add() is outstanding is rejected', async () => {
|
|
const { filePath, cleanup } = await tempZip(
|
|
[await zlib.ZipEntry.create('a.txt', Buffer.from('data'))]);
|
|
try {
|
|
const zip = await zlib.ZipFile.open(filePath, { writable: true });
|
|
// add() runs synchronously up to `await ZipEntry.create(...)` and then
|
|
// suspends, having registered nothing yet.
|
|
const adding = zip.add('b.txt', Buffer.alloc(1 << 16, 7));
|
|
// The mutation must already be reserved, so a synchronous method sees the
|
|
// archive as busy instead of racing the descriptor.
|
|
assert.throws(() => zip.closeSync(), { code: 'ERR_INVALID_STATE' });
|
|
await adding;
|
|
await zip.close();
|
|
|
|
const check = await zlib.ZipFile.open(filePath);
|
|
try {
|
|
assert.ok(check.has('b.txt'), 'the added entry must survive');
|
|
} finally {
|
|
await check.close();
|
|
}
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
test('an earlier add() completes before a later close()', async () => {
|
|
const { filePath, cleanup } = await tempZip(
|
|
[await zlib.ZipEntry.create('a.txt', Buffer.from('data'))]);
|
|
try {
|
|
const zip = await zlib.ZipFile.open(filePath, { writable: true });
|
|
const adding = zip.add('b.txt', Buffer.alloc(1 << 16, 9));
|
|
const closing = zip.close();
|
|
// add() was issued first; it must land before close() tears down the fd.
|
|
await Promise.all([adding, closing]);
|
|
|
|
const check = await zlib.ZipFile.open(filePath);
|
|
try {
|
|
assert.ok(check.has('b.txt'), 'the earlier add() must not be lost');
|
|
} finally {
|
|
await check.close();
|
|
}
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
// -- Finding 3 (root cause): zipFiles() classifies via lstat() and then
|
|
// re-opens the path with createReadStream(), so it neither validates the
|
|
// opened descriptor's type nor pins it against a swap. A non-regular file
|
|
// therefore slips through as an ordinary stream source. --------------------
|
|
|
|
test('zipFiles() rejects a non-regular special file', {
|
|
skip: common.isWindows ? 'no /dev/null semantics on Windows' : false,
|
|
}, async () => {
|
|
const chunks = [];
|
|
await assert.rejects(
|
|
(async () => {
|
|
for await (const chunk of zlib.zipFiles([['/dev/null', 'null']])) chunks.push(chunk);
|
|
})(),
|
|
(err) => err?.code !== undefined && err.code !== 'ERR_ASSERTION',
|
|
'archiving a character device should be rejected, not stored as an empty file');
|
|
});
|
|
|
|
// -- Concern B (verdict: by design): the default decompression ceiling guards
|
|
// the buffering path (content()) against a huge allocation; streaming
|
|
// (contentIterator / ZipFile.stream) is deliberately not bound by it, so a
|
|
// legitimately large member can be read chunk by chunk. Output is still capped
|
|
// per chunk at the declared size, and a caller wanting a cap passes maxSize.
|
|
// This locks in that asymmetry (a default ceiling on streaming would break
|
|
// multi-gigabyte reads - see test/pummel/test-zlib-zip-slow.js). -------------
|
|
|
|
test('the default ceiling bounds content() but not streaming', async () => {
|
|
const chunks = [];
|
|
for await (const c of zlib.createZipArchive(
|
|
[await zlib.ZipEntry.create('big.txt', Buffer.alloc(4096, 1), { method: 'store' })])) {
|
|
chunks.push(c);
|
|
}
|
|
using zip = new zlib.ZipBuffer(Buffer.concat(chunks));
|
|
const entry = zip.get('big.txt');
|
|
|
|
const saved = zlib.getMaxZipContentSize();
|
|
zlib.setMaxZipContentSize(1024); // Below the entry's 4096 declared bytes.
|
|
try {
|
|
// One-shot buffering enforces the default ceiling...
|
|
await assert.rejects(entry.content(), { code: 'ERR_ZIP_ENTRY_TOO_LARGE' });
|
|
// ...but streaming (the bounded-memory path) is not capped by it.
|
|
let total = 0;
|
|
for await (const chunk of entry.contentIterator()) total += chunk.length;
|
|
assert.strictEqual(total, 4096);
|
|
// An explicit maxSize still caps streaming when the caller wants it.
|
|
await assert.rejects((async () => {
|
|
let n = 0;
|
|
for await (const chunk of entry.contentIterator({ maxSize: 1024 })) n += chunk.length;
|
|
return n;
|
|
})(), { code: 'ERR_ZIP_ENTRY_TOO_LARGE' });
|
|
} finally {
|
|
zlib.setMaxZipContentSize(saved);
|
|
}
|
|
});
|
|
|
|
// -- Concern C: when a Zip64 end record is present its values silently override
|
|
// the classic EOCD without checking they agree. A non-sentinel classic field
|
|
// that disagrees with Zip64 is a parser differential (a classic-only tool and
|
|
// Node see different archives) and must be rejected. --------------------------
|
|
|
|
test('open() rejects contradictory classic-EOCD vs Zip64 metadata', async () => {
|
|
// 0x10000 entries forces Zip64; the classic total-records field becomes the
|
|
// 0xFFFF overflow sentinel.
|
|
const entries = [];
|
|
for (let i = 0; i < 0x10000; i++) {
|
|
entries.push(zlib.ZipEntry.createSync(`e${i}`, Buffer.alloc(0), { method: 'store' }));
|
|
}
|
|
const bytes = Buffer.concat([...zlib.createZipArchiveSync(entries)]);
|
|
|
|
const eocd = bytes.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]));
|
|
assert.ok(eocd >= 0, 'classic EOCD present');
|
|
// The classic total-records field must be the 0xFFFF overflow sentinel.
|
|
assert.strictEqual(bytes.readUInt16LE(eocd + 10), 0xFFFF);
|
|
// Rewrite the sentinel to a smaller, non-sentinel value that disagrees with
|
|
// the Zip64 record (which says 0x10000).
|
|
const tampered = Buffer.from(bytes);
|
|
tampered.writeUInt16LE(3, eocd + 10);
|
|
|
|
assert.throws(() => new zlib.ZipBuffer(tampered), { code: 'ERR_ZIP_INVALID_ARCHIVE' });
|
|
|
|
const dir = await fsp.mkdtemp(path.join(tmpdir.path, `zip-sec-${seq++}-`));
|
|
const p = path.join(dir, 'contradiction.zip');
|
|
try {
|
|
await fsp.writeFile(p, tampered);
|
|
await assert.rejects(zlib.ZipFile.open(p), { code: 'ERR_ZIP_INVALID_ARCHIVE' });
|
|
assert.throws(() => zlib.ZipFile.openSync(p), { code: 'ERR_ZIP_INVALID_ARCHIVE' });
|
|
} finally {
|
|
await fsp.rm(dir, { recursive: true, force: true });
|
|
}
|
|
}, { timeout: 120_000 });
|
|
|
|
// -- Finding 4: the file-backed open-time overlap check uses a 30-byte lower
|
|
// bound for each local header, while the in-memory reader uses the exact
|
|
// local-header length. A crafted "quoted overlap" archive therefore passes
|
|
// ZipFile.open() but is (correctly) rejected by ZipBuffer. -----------------
|
|
|
|
test('ZipFile.open() enforces the same member-overlap check as ZipBuffer', async () => {
|
|
const chunks = [];
|
|
for await (const chunk of zlib.createZipArchive([
|
|
await zlib.ZipEntry.create('A', Buffer.alloc(40, 1), { method: 'store' }),
|
|
await zlib.ZipEntry.create('B', Buffer.alloc(40, 2), { method: 'store' }),
|
|
])) chunks.push(chunk);
|
|
const tampered = Buffer.concat(chunks);
|
|
// Enlarge entry A's *local* extra-field length. Its real data range (which
|
|
// the read path locates via the local header) now runs past entry B's local
|
|
// header - the quoted/overlapping zip-bomb shape. The central directory is
|
|
// left untouched, so the offset+30+compressedSize open check still sees room.
|
|
tampered.writeUInt16LE(tampered.readUInt16LE(28) + 8, 28);
|
|
|
|
// The in-memory reader rejects it outright.
|
|
assert.throws(() => new zlib.ZipBuffer(tampered), { code: 'ERR_ZIP_INVALID_ARCHIVE' });
|
|
|
|
// The file-backed reader must reject it too, at open time - not silently
|
|
// accept an archive whose members overlap.
|
|
const dir = await fsp.mkdtemp(path.join(tmpdir.path, `zip-sec-${seq++}-`));
|
|
const p = path.join(dir, 'overlap.zip');
|
|
try {
|
|
await fsp.writeFile(p, tampered);
|
|
await assert.rejects(zlib.ZipFile.open(p), { code: 'ERR_ZIP_INVALID_ARCHIVE' });
|
|
assert.throws(() => zlib.ZipFile.openSync(p), { code: 'ERR_ZIP_INVALID_ARCHIVE' });
|
|
} finally {
|
|
await fsp.rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|